chore: repo-wide normalization + rules test coverage
Normalize formatting/line endings across configs, controllers, models, tests, and OpenAPI specs. Update rule expression/rule engine implementation and remove obsolete RuleAction controller/model. Add unit tests for rule expression syntax and multi-action behavior, and include docs updates.
This commit is contained in:
parent
c01786bb93
commit
2bcdf09b55
@ -357,11 +357,6 @@ $routes->group('api', function ($routes) {
|
|||||||
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
|
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
|
||||||
$routes->post('validate', 'Rule\RuleController::validateExpr');
|
$routes->post('validate', 'Rule\RuleController::validateExpr');
|
||||||
$routes->post('compile', 'Rule\RuleController::compile');
|
$routes->post('compile', 'Rule\RuleController::compile');
|
||||||
|
|
||||||
$routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
|
|
||||||
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');
|
|
||||||
$routes->patch('(:num)/actions/(:num)', 'Rule\RuleActionController::update/$1/$2');
|
|
||||||
$routes->delete('(:num)/actions/(:num)', 'Rule\RuleActionController::delete/$1/$2');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Demo/Test Routes (No Auth)
|
// Demo/Test Routes (No Auth)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ use App\Libraries\ValueSet;
|
|||||||
use App\Models\OrderTest\OrderTestModel;
|
use App\Models\OrderTest\OrderTestModel;
|
||||||
use App\Models\Patient\PatientModel;
|
use App\Models\Patient\PatientModel;
|
||||||
use App\Models\PatVisit\PatVisitModel;
|
use App\Models\PatVisit\PatVisitModel;
|
||||||
use App\Services\RuleEngineService;
|
|
||||||
|
|
||||||
class OrderTestController extends Controller {
|
class OrderTestController extends Controller {
|
||||||
use ResponseTrait;
|
use ResponseTrait;
|
||||||
@ -180,20 +179,7 @@ class OrderTestController extends Controller {
|
|||||||
$order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']);
|
$order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']);
|
||||||
$order['Tests'] = $this->getOrderTests($order['InternalOID']);
|
$order['Tests'] = $this->getOrderTests($order['InternalOID']);
|
||||||
|
|
||||||
// Run common rules for ORDER_CREATED (non-blocking)
|
// Rule engine triggers are fired at the test/result level (test_created, result_updated)
|
||||||
try {
|
|
||||||
$ruleEngine = new RuleEngineService();
|
|
||||||
$ruleEngine->run('ORDER_CREATED', [
|
|
||||||
'order' => $order,
|
|
||||||
'tests' => $order['Tests'],
|
|
||||||
'input' => $input,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Refresh tests in case rules updated results
|
|
||||||
$order['Tests'] = $this->getOrderTests($order['InternalOID']);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'OrderTestController::create rule engine error: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->respondCreated([
|
return $this->respondCreated([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
|
|||||||
@ -1,227 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers\Rule;
|
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
|
||||||
use App\Models\Rule\RuleActionModel;
|
|
||||||
use App\Models\Rule\RuleDefModel;
|
|
||||||
use App\Models\Test\TestDefSiteModel;
|
|
||||||
use App\Traits\ResponseTrait;
|
|
||||||
|
|
||||||
class RuleActionController extends BaseController
|
|
||||||
{
|
|
||||||
use ResponseTrait;
|
|
||||||
|
|
||||||
protected RuleDefModel $ruleDefModel;
|
|
||||||
protected RuleActionModel $ruleActionModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->ruleDefModel = new RuleDefModel();
|
|
||||||
$this->ruleActionModel = new RuleActionModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index($ruleID = null)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$ruleID || !is_numeric($ruleID)) {
|
|
||||||
return $this->failValidationErrors('RuleID is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
|
|
||||||
if (!$rule) {
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Rule not found',
|
|
||||||
'data' => [],
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $this->ruleActionModel
|
|
||||||
->where('RuleID', (int) $ruleID)
|
|
||||||
->where('EndDate', null)
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => 'fetch success',
|
|
||||||
'data' => $rows,
|
|
||||||
], 200);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'RuleActionController::index error: ' . $e->getMessage());
|
|
||||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create($ruleID = null)
|
|
||||||
{
|
|
||||||
$input = $this->request->getJSON(true) ?? [];
|
|
||||||
|
|
||||||
if (!$ruleID || !is_numeric($ruleID)) {
|
|
||||||
$ruleID = $input['RuleID'] ?? null;
|
|
||||||
}
|
|
||||||
if (!$ruleID || !is_numeric($ruleID)) {
|
|
||||||
return $this->failValidationErrors('RuleID is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
|
|
||||||
if (!$rule) {
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Rule not found',
|
|
||||||
'data' => [],
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validation = service('validation');
|
|
||||||
$validation->setRules([
|
|
||||||
'ActionType' => 'required|max_length[50]',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!$validation->run($input)) {
|
|
||||||
return $this->failValidationErrors($validation->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Light validation for SET_RESULT params
|
|
||||||
$actionType = strtoupper((string) $input['ActionType']);
|
|
||||||
$params = $input['ActionParams'] ?? null;
|
|
||||||
if ($actionType === 'SET_RESULT') {
|
|
||||||
$decoded = is_array($params) ? $params : (is_string($params) ? json_decode($params, true) : null);
|
|
||||||
if (!is_array($decoded)) {
|
|
||||||
return $this->failValidationErrors(['ActionParams' => 'ActionParams must be JSON object for SET_RESULT']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($decoded['testSiteID']) && empty($decoded['testSiteCode'])) {
|
|
||||||
return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires testSiteID or testSiteCode']);
|
|
||||||
}
|
|
||||||
if (!array_key_exists('value', $decoded) && !array_key_exists('valueExpr', $decoded)) {
|
|
||||||
return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires value or valueExpr']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($decoded['testSiteID']) && is_numeric($decoded['testSiteID'])) {
|
|
||||||
$testDef = new TestDefSiteModel();
|
|
||||||
$exists = $testDef->where('EndDate', null)->find((int) $decoded['testSiteID']);
|
|
||||||
if (!$exists) {
|
|
||||||
return $this->failValidationErrors(['ActionParams' => 'testSiteID not found']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (is_array($params)) {
|
|
||||||
$params = json_encode($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = $this->ruleActionModel->insert([
|
|
||||||
'RuleID' => (int) $ruleID,
|
|
||||||
'ActionType' => $input['ActionType'],
|
|
||||||
'ActionParams' => is_string($params) ? $params : null,
|
|
||||||
], true);
|
|
||||||
|
|
||||||
return $this->respondCreated([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => 'Action created successfully',
|
|
||||||
'data' => ['RuleActionID' => $id],
|
|
||||||
], 201);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'RuleActionController::create error: ' . $e->getMessage());
|
|
||||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update($ruleID = null, $actionID = null)
|
|
||||||
{
|
|
||||||
$input = $this->request->getJSON(true) ?? [];
|
|
||||||
|
|
||||||
if (!$ruleID || !is_numeric($ruleID)) {
|
|
||||||
$ruleID = $input['RuleID'] ?? null;
|
|
||||||
}
|
|
||||||
if (!$actionID || !is_numeric($actionID)) {
|
|
||||||
$actionID = $input['RuleActionID'] ?? null;
|
|
||||||
}
|
|
||||||
if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) {
|
|
||||||
return $this->failValidationErrors('RuleID and RuleActionID are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID);
|
|
||||||
if (!$rule) {
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Rule not found',
|
|
||||||
'data' => [],
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID);
|
|
||||||
if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) {
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Action not found',
|
|
||||||
'data' => [],
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validation = service('validation');
|
|
||||||
$validation->setRules([
|
|
||||||
'ActionType' => 'permit_empty|max_length[50]',
|
|
||||||
]);
|
|
||||||
if (!$validation->run($input)) {
|
|
||||||
return $this->failValidationErrors($validation->getErrors());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$updateData = [];
|
|
||||||
foreach (['ActionType', 'ActionParams'] as $field) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$updateData[$field] = $input[$field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($updateData['ActionParams']) && is_array($updateData['ActionParams'])) {
|
|
||||||
$updateData['ActionParams'] = json_encode($updateData['ActionParams']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($updateData)) {
|
|
||||||
$this->ruleActionModel->update((int) $actionID, $updateData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => 'Action updated successfully',
|
|
||||||
'data' => ['RuleActionID' => (int) $actionID],
|
|
||||||
], 200);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'RuleActionController::update error: ' . $e->getMessage());
|
|
||||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete($ruleID = null, $actionID = null)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) {
|
|
||||||
return $this->failValidationErrors('RuleID and RuleActionID are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID);
|
|
||||||
if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) {
|
|
||||||
return $this->respond([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Action not found',
|
|
||||||
'data' => [],
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->ruleActionModel->delete((int) $actionID);
|
|
||||||
|
|
||||||
return $this->respondDeleted([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => 'Action deleted successfully',
|
|
||||||
'data' => ['RuleActionID' => (int) $actionID],
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
log_message('error', 'RuleActionController::delete error: ' . $e->getMessage());
|
|
||||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Controllers\Rule;
|
namespace App\Controllers\Rule;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\Rule\RuleActionModel;
|
|
||||||
use App\Models\Rule\RuleDefModel;
|
use App\Models\Rule\RuleDefModel;
|
||||||
use App\Models\Test\TestDefSiteModel;
|
use App\Models\Test\TestDefSiteModel;
|
||||||
use App\Services\RuleExpressionService;
|
use App\Services\RuleExpressionService;
|
||||||
@ -14,12 +13,10 @@ class RuleController extends BaseController
|
|||||||
use ResponseTrait;
|
use ResponseTrait;
|
||||||
|
|
||||||
protected RuleDefModel $ruleDefModel;
|
protected RuleDefModel $ruleDefModel;
|
||||||
protected RuleActionModel $ruleActionModel;
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->ruleDefModel = new RuleDefModel();
|
$this->ruleDefModel = new RuleDefModel();
|
||||||
$this->ruleActionModel = new RuleActionModel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@ -80,15 +77,8 @@ class RuleController extends BaseController
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$actions = $this->ruleActionModel
|
|
||||||
->where('RuleID', (int) $id)
|
|
||||||
->where('EndDate', null)
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
|
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
|
||||||
|
|
||||||
$rule['actions'] = $actions;
|
|
||||||
$rule['linkedTests'] = $linkedTests;
|
$rule['linkedTests'] = $linkedTests;
|
||||||
|
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
@ -161,31 +151,6 @@ class RuleController extends BaseController
|
|||||||
$this->ruleDefModel->linkTest($ruleID, (int) $testSiteID);
|
$this->ruleDefModel->linkTest($ruleID, (int) $testSiteID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create actions if provided
|
|
||||||
if (isset($input['actions']) && is_array($input['actions'])) {
|
|
||||||
foreach ($input['actions'] as $action) {
|
|
||||||
if (!is_array($action)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actionType = $action['ActionType'] ?? null;
|
|
||||||
if (!$actionType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$params = $action['ActionParams'] ?? null;
|
|
||||||
if (is_array($params)) {
|
|
||||||
$params = json_encode($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->ruleActionModel->insert([
|
|
||||||
'RuleID' => $ruleID,
|
|
||||||
'ActionType' => $actionType,
|
|
||||||
'ActionParams' => is_string($params) ? $params : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
if ($db->transStatus() === false) {
|
if ($db->transStatus() === false) {
|
||||||
throw new \Exception('Transaction failed');
|
throw new \Exception('Transaction failed');
|
||||||
|
|||||||
@ -5,8 +5,9 @@ namespace App\Database\Migrations;
|
|||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace ruledef/ruleaction with testrule schema
|
* Rule engine tables: ruledef + testrule.
|
||||||
* Rules can now be linked to multiple tests via testrule_testsite mapping table
|
*
|
||||||
|
* ruleaction is deprecated; actions are embedded in ruledef.ConditionExprCompiled.
|
||||||
*/
|
*/
|
||||||
class CreateTestRules extends Migration
|
class CreateTestRules extends Migration
|
||||||
{
|
{
|
||||||
@ -46,26 +47,10 @@ class CreateTestRules extends Migration
|
|||||||
// Foreign keys for mapping table
|
// Foreign keys for mapping table
|
||||||
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
|
|
||||||
// ruleaction - actions for rules
|
|
||||||
$this->forge->addField([
|
|
||||||
'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
|
||||||
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
|
|
||||||
'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
|
||||||
'ActionParams' => ['type' => 'TEXT', 'null' => true],
|
|
||||||
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
|
||||||
'EndDate' => ['type' => 'DATETIME', 'null' => true],
|
|
||||||
]);
|
|
||||||
$this->forge->addKey('RuleActionID', true);
|
|
||||||
$this->forge->addKey('RuleID');
|
|
||||||
$this->forge->createTable('ruleaction');
|
|
||||||
|
|
||||||
$this->db->query('ALTER TABLE `ruleaction` ADD CONSTRAINT `fk_ruleaction_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down()
|
public function down()
|
||||||
{
|
{
|
||||||
$this->forge->dropTable('ruleaction', true);
|
|
||||||
$this->forge->dropTable('testrule', true);
|
$this->forge->dropTable('testrule', true);
|
||||||
$this->forge->dropTable('ruledef', true);
|
$this->forge->dropTable('ruledef', true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,20 @@ class OrderTestModel extends BaseModel {
|
|||||||
// Insert unique tests into patres with specimen links
|
// Insert unique tests into patres with specimen links
|
||||||
if (!empty($testToOrder)) {
|
if (!empty($testToOrder)) {
|
||||||
$resModel = new \App\Models\PatResultModel();
|
$resModel = new \App\Models\PatResultModel();
|
||||||
|
$patientModel = new \App\Models\Patient\PatientModel();
|
||||||
|
$patient = $patientModel->find((int) $data['InternalPID']);
|
||||||
|
$age = null;
|
||||||
|
if (is_array($patient) && !empty($patient['Birthdate'])) {
|
||||||
|
try {
|
||||||
|
$birthdate = new \DateTime((string) $patient['Birthdate']);
|
||||||
|
$age = (new \DateTime())->diff($birthdate)->y;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$age = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ruleEngine = new \App\Services\RuleEngineService();
|
||||||
|
|
||||||
foreach ($testToOrder as $tid => $tinfo) {
|
foreach ($testToOrder as $tid => $tinfo) {
|
||||||
$specimenInfo = $specimenConDefMap[$tid] ?? null;
|
$specimenInfo = $specimenConDefMap[$tid] ?? null;
|
||||||
|
|
||||||
@ -182,6 +196,24 @@ class OrderTestModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resModel->insert($patResData);
|
$resModel->insert($patResData);
|
||||||
|
|
||||||
|
// Fire test_created rules after the test row is persisted
|
||||||
|
try {
|
||||||
|
$ruleEngine->run('test_created', [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
'Priority' => $orderData['Priority'] ?? 'R',
|
||||||
|
'TestSiteID' => $tid,
|
||||||
|
],
|
||||||
|
'patient' => [
|
||||||
|
'Sex' => is_array($patient) ? ($patient['Sex'] ?? null) : null,
|
||||||
|
],
|
||||||
|
'age' => $age,
|
||||||
|
'testSiteID' => $tid,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'OrderTestModel::createOrder rule engine error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -214,6 +214,47 @@ class PatResultModel extends BaseModel {
|
|||||||
|
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
|
|
||||||
|
// Fire result_updated rules (non-blocking)
|
||||||
|
try {
|
||||||
|
$fresh = $this->find($resultID);
|
||||||
|
if (is_array($fresh) && !empty($fresh['OrderID']) && !empty($fresh['TestSiteID'])) {
|
||||||
|
$orderModel = new \App\Models\OrderTest\OrderTestModel();
|
||||||
|
$order = $orderModel->find((int) $fresh['OrderID']);
|
||||||
|
|
||||||
|
$patient = null;
|
||||||
|
$age = null;
|
||||||
|
if (is_array($order) && !empty($order['InternalPID'])) {
|
||||||
|
$patientModel = new \App\Models\Patient\PatientModel();
|
||||||
|
$patient = $patientModel->find((int) $order['InternalPID']);
|
||||||
|
if (is_array($patient) && !empty($patient['Birthdate'])) {
|
||||||
|
try {
|
||||||
|
$birthdate = new \DateTime((string) $patient['Birthdate']);
|
||||||
|
$age = (new \DateTime())->diff($birthdate)->y;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$age = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$engine = new \App\Services\RuleEngineService();
|
||||||
|
$engine->run('result_updated', [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => (int) ($fresh['OrderID'] ?? 0),
|
||||||
|
'Priority' => is_array($order) ? ($order['Priority'] ?? null) : null,
|
||||||
|
'TestSiteID' => (int) ($fresh['TestSiteID'] ?? 0),
|
||||||
|
],
|
||||||
|
'patient' => [
|
||||||
|
'Sex' => is_array($patient) ? ($patient['Sex'] ?? null) : null,
|
||||||
|
],
|
||||||
|
'age' => $age,
|
||||||
|
'testSiteID' => (int) ($fresh['TestSiteID'] ?? 0),
|
||||||
|
'result' => $fresh,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'PatResultModel::updateWithValidation rule engine error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'flag' => $flag,
|
'flag' => $flag,
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models\Rule;
|
|
||||||
|
|
||||||
use App\Models\BaseModel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RuleAction Model
|
|
||||||
*
|
|
||||||
* Actions that can be executed when a rule matches.
|
|
||||||
*/
|
|
||||||
class RuleActionModel extends BaseModel
|
|
||||||
{
|
|
||||||
protected $table = 'ruleaction';
|
|
||||||
protected $primaryKey = 'RuleActionID';
|
|
||||||
protected $allowedFields = [
|
|
||||||
'RuleID',
|
|
||||||
'ActionType',
|
|
||||||
'ActionParams',
|
|
||||||
'CreateDate',
|
|
||||||
'EndDate',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
|
||||||
protected $createdField = 'CreateDate';
|
|
||||||
protected $updatedField = '';
|
|
||||||
|
|
||||||
protected $useSoftDeletes = true;
|
|
||||||
protected $deletedField = 'EndDate';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active actions by rule IDs
|
|
||||||
*
|
|
||||||
* @param array $ruleIDs Array of RuleID values
|
|
||||||
* @return array Array of actions
|
|
||||||
*/
|
|
||||||
public function getActiveByRuleIDs(array $ruleIDs): array
|
|
||||||
{
|
|
||||||
if (empty($ruleIDs)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->whereIn('RuleID', $ruleIDs)
|
|
||||||
->where('EndDate IS NULL')
|
|
||||||
->orderBy('RuleID', 'ASC')
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -137,4 +137,144 @@ class RuleDefModel extends BaseModel
|
|||||||
->where('EndDate IS NULL')
|
->where('EndDate IS NULL')
|
||||||
->update(['EndDate' => date('Y-m-d H:i:s')]);
|
->update(['EndDate' => date('Y-m-d H:i:s')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a DSL expression compiles successfully
|
||||||
|
*
|
||||||
|
* @param string $expr The DSL expression to validate
|
||||||
|
* @return array ['valid' => bool, 'error' => string|null, 'compiled' => array|null]
|
||||||
|
*/
|
||||||
|
public function validateExpression(string $expr): array
|
||||||
|
{
|
||||||
|
$service = new \App\Services\RuleExpressionService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$compiled = $service->compile($expr);
|
||||||
|
return [
|
||||||
|
'valid' => true,
|
||||||
|
'error' => null,
|
||||||
|
'compiled' => $compiled,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'compiled' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a DSL expression to JSON
|
||||||
|
*
|
||||||
|
* @param string $expr The DSL expression to compile
|
||||||
|
* @return array The compiled structure
|
||||||
|
* @throws \InvalidArgumentException If compilation fails
|
||||||
|
*/
|
||||||
|
public function compileExpression(string $expr): array
|
||||||
|
{
|
||||||
|
$service = new \App\Services\RuleExpressionService();
|
||||||
|
return $service->compile($expr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a rule's expression needs recompilation
|
||||||
|
* Returns true if the raw expression differs from the compiled version
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID to check
|
||||||
|
* @return bool True if recompilation is needed
|
||||||
|
*/
|
||||||
|
public function needsRecompilation(int $ruleID): bool
|
||||||
|
{
|
||||||
|
$rule = $this->find($ruleID);
|
||||||
|
if (!$rule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawExpr = $rule['ConditionExpr'] ?? '';
|
||||||
|
$compiledJson = $rule['ConditionExprCompiled'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawExpr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($compiledJson)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to compile current expression and compare with stored version
|
||||||
|
try {
|
||||||
|
$compiled = $this->compileExpression($rawExpr);
|
||||||
|
$stored = json_decode($compiledJson, true);
|
||||||
|
|
||||||
|
return json_encode($compiled) !== json_encode($stored);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically compile expression if needed and update the rule
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function autoCompile(int $ruleID): bool
|
||||||
|
{
|
||||||
|
if (!$this->needsRecompilation($ruleID)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = $this->find($ruleID);
|
||||||
|
if (!$rule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawExpr = $rule['ConditionExpr'] ?? '';
|
||||||
|
if (empty($rawExpr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$compiled = $this->compileExpression($rawExpr);
|
||||||
|
return $this->update($ruleID, [
|
||||||
|
'ConditionExprCompiled' => json_encode($compiled),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Auto-compile failed for RuleID=' . $ruleID . ': ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile all rules that need recompilation
|
||||||
|
*
|
||||||
|
* @return array ['success' => int, 'failed' => int]
|
||||||
|
*/
|
||||||
|
public function compileAllPending(): array
|
||||||
|
{
|
||||||
|
$rules = $this->where('EndDate IS NULL')->findAll();
|
||||||
|
$success = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
$ruleID = (int) ($rule['RuleID'] ?? 0);
|
||||||
|
if ($ruleID === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->needsRecompilation($ruleID)) {
|
||||||
|
if ($this->autoCompile($ruleID)) {
|
||||||
|
$success++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $success,
|
||||||
|
'failed' => $failed,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,29 +2,31 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Rule\RuleActionModel;
|
|
||||||
use App\Models\Rule\RuleDefModel;
|
use App\Models\Rule\RuleDefModel;
|
||||||
use App\Models\Test\TestDefSiteModel;
|
use App\Models\Test\TestDefSiteModel;
|
||||||
|
|
||||||
class RuleEngineService
|
class RuleEngineService
|
||||||
{
|
{
|
||||||
protected RuleDefModel $ruleDefModel;
|
protected RuleDefModel $ruleDefModel;
|
||||||
protected RuleActionModel $ruleActionModel;
|
|
||||||
protected RuleExpressionService $expr;
|
protected RuleExpressionService $expr;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->ruleDefModel = new RuleDefModel();
|
$this->ruleDefModel = new RuleDefModel();
|
||||||
$this->ruleActionModel = new RuleActionModel();
|
|
||||||
$this->expr = new RuleExpressionService();
|
$this->expr = new RuleExpressionService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run rules for an event.
|
* Run rules for an event.
|
||||||
*
|
*
|
||||||
* Expected context keys for ORDER_CREATED:
|
* Expected context keys:
|
||||||
* - order: array (must include InternalOID)
|
* - order: array (must include InternalOID)
|
||||||
* - tests: array (patres rows, optional)
|
* - 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
|
public function run(string $eventCode, array $context = []): void
|
||||||
{
|
{
|
||||||
@ -40,18 +42,6 @@ class RuleEngineService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ruleIDs = array_values(array_filter(array_map(static fn ($r) => $r['RuleID'] ?? null, $rules)));
|
|
||||||
$actions = $this->ruleActionModel->getActiveByRuleIDs($ruleIDs);
|
|
||||||
|
|
||||||
$actionsByRule = [];
|
|
||||||
foreach ($actions as $action) {
|
|
||||||
$rid = $action['RuleID'] ?? null;
|
|
||||||
if (!$rid) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$actionsByRule[$rid][] = $action;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
$rid = (int) ($rule['RuleID'] ?? 0);
|
$rid = (int) ($rule['RuleID'] ?? 0);
|
||||||
if ($rid === 0) {
|
if ($rid === 0) {
|
||||||
@ -59,35 +49,33 @@ class RuleEngineService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for compiled expression first
|
// Rules must have compiled expressions
|
||||||
$compiled = null;
|
$compiled = null;
|
||||||
if (!empty($rule['ConditionExprCompiled'])) {
|
if (!empty($rule['ConditionExprCompiled'])) {
|
||||||
$compiled = json_decode($rule['ConditionExprCompiled'], true);
|
$compiled = json_decode($rule['ConditionExprCompiled'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($compiled) && is_array($compiled)) {
|
if (empty($compiled) || !is_array($compiled)) {
|
||||||
// Compiled rule: evaluate condition from compiled structure
|
log_message('warning', 'Rule ' . $rid . ' has no compiled expression, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate condition
|
||||||
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
|
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
|
||||||
$matches = $this->expr->evaluateBoolean($conditionExpr, $context);
|
$matches = $this->evaluateCondition($conditionExpr, $context);
|
||||||
if (!$matches) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use compiled valueExpr for SET_RESULT action
|
if (!$matches) {
|
||||||
if (!empty($compiled['valueExpr'])) {
|
// Execute else actions
|
||||||
$this->executeCompiledSetResult($rid, $compiled['valueExpr'], $context);
|
$actions = $compiled['else'] ?? [];
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Legacy rule: evaluate raw ConditionExpr and execute stored actions
|
// Execute then actions
|
||||||
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context);
|
$actions = $compiled['then'] ?? [];
|
||||||
if (!$matches) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($actionsByRule[$rid] ?? [] as $action) {
|
// Execute all actions
|
||||||
|
foreach ($actions as $action) {
|
||||||
$this->executeAction($action, $context);
|
$this->executeAction($action, $context);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
|
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
|
||||||
continue;
|
continue;
|
||||||
@ -96,10 +84,90 @@ class RuleEngineService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute SET_RESULT action using compiled valueExpr.
|
* Evaluate a condition expression
|
||||||
* Automatically creates the test result if it doesn't exist.
|
* Handles special functions like requested() by querying the database
|
||||||
*/
|
*/
|
||||||
protected function executeCompiledSetResult(int $ruleID, string $valueExpr, array $context): void
|
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;
|
$order = $context['order'] ?? null;
|
||||||
if (!is_array($order) || empty($order['InternalOID'])) {
|
if (!is_array($order) || empty($order['InternalOID'])) {
|
||||||
@ -113,20 +181,16 @@ class RuleEngineService
|
|||||||
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
|
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($testSiteID === null) {
|
|
||||||
// Try to get testSiteID from context tests
|
|
||||||
$tests = $context['tests'] ?? [];
|
|
||||||
if (!empty($tests) && is_array($tests)) {
|
|
||||||
$testSiteID = (int) ($tests[0]['TestSiteID'] ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($testSiteID === null) {
|
if ($testSiteID === null) {
|
||||||
throw new \Exception('SET_RESULT requires testSiteID');
|
throw new \Exception('SET_RESULT requires testSiteID');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate the value expression
|
// Get the value
|
||||||
$value = $this->expr->evaluate($valueExpr, $context);
|
if (isset($action['valueExpr']) && is_string($action['valueExpr'])) {
|
||||||
|
$value = $this->expr->evaluate($action['valueExpr'], $context);
|
||||||
|
} else {
|
||||||
|
$value = $action['value'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
@ -150,6 +214,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),
|
||||||
'Result' => $value,
|
'Result' => $value,
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
@ -160,72 +225,139 @@ class RuleEngineService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function executeAction(array $action, array $context): void
|
|
||||||
{
|
|
||||||
$type = strtoupper((string) ($action['ActionType'] ?? ''));
|
|
||||||
|
|
||||||
if ($type === 'SET_RESULT') {
|
|
||||||
$this->executeSetResult($action, $context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown action type: ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SET_RESULT action params (JSON):
|
* Execute INSERT_TEST action - Insert a new test into patres
|
||||||
* - testSiteID (int) OR testSiteCode (string)
|
|
||||||
* - value (scalar) OR valueExpr (ExpressionLanguage string)
|
|
||||||
*/
|
*/
|
||||||
protected function executeSetResult(array $action, array $context): void
|
protected function executeInsertTest(array $action, array $context): void
|
||||||
{
|
{
|
||||||
$paramsRaw = (string) ($action['ActionParams'] ?? '');
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if (trim($paramsRaw) !== '') {
|
|
||||||
$decoded = json_decode($paramsRaw, true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
$params = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$order = $context['order'] ?? null;
|
$order = $context['order'] ?? null;
|
||||||
if (!is_array($order) || empty($order['InternalOID'])) {
|
if (!is_array($order) || empty($order['InternalOID'])) {
|
||||||
throw new \Exception('SET_RESULT requires context.order.InternalOID');
|
throw new \Exception('INSERT_TEST requires context.order.InternalOID');
|
||||||
}
|
}
|
||||||
|
|
||||||
$internalOID = (int) $order['InternalOID'];
|
$internalOID = (int) $order['InternalOID'];
|
||||||
|
$testCode = $action['testCode'] ?? null;
|
||||||
|
|
||||||
$testSiteID = isset($params['testSiteID']) && is_numeric($params['testSiteID'])
|
if (empty($testCode)) {
|
||||||
? (int) $params['testSiteID']
|
throw new \Exception('INSERT_TEST requires testCode');
|
||||||
: null;
|
}
|
||||||
|
|
||||||
if ($testSiteID === null && !empty($params['testSiteCode'])) {
|
// Look up TestSiteID from TestSiteCode
|
||||||
$testSiteCode = (string) $params['testSiteCode'];
|
|
||||||
$testDefSiteModel = new TestDefSiteModel();
|
$testDefSiteModel = new TestDefSiteModel();
|
||||||
$row = $testDefSiteModel->where('TestSiteCode', $testSiteCode)->where('EndDate', null)->first();
|
$testSite = $testDefSiteModel->where('TestSiteCode', $testCode)
|
||||||
$testSiteID = isset($row['TestSiteID']) ? (int) $row['TestSiteID'] : null;
|
->where('EndDate', null)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$testSite || empty($testSite['TestSiteID'])) {
|
||||||
|
throw new \Exception('INSERT_TEST: Test not found with code: ' . $testCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($testSiteID === null) {
|
$testSiteID = (int) $testSite['TestSiteID'];
|
||||||
throw new \Exception('SET_RESULT requires testSiteID or testSiteCode');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('valueExpr', $params) && is_string($params['valueExpr'])) {
|
|
||||||
$value = $this->expr->evaluate($params['valueExpr'], $context);
|
|
||||||
} else {
|
|
||||||
$value = $params['value'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$ok = $db->table('patres')
|
|
||||||
|
// Check if test already exists (avoid duplicates)
|
||||||
|
$existing = $db->table('patres')
|
||||||
->where('OrderID', $internalOID)
|
->where('OrderID', $internalOID)
|
||||||
->where('TestSiteID', $testSiteID)
|
->where('TestSiteID', $testSiteID)
|
||||||
->where('DelDate', null)
|
->where('DelDate', null)
|
||||||
->update(['Result' => $value]);
|
->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) {
|
if ($ok === false) {
|
||||||
throw new \Exception('SET_RESULT update failed');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,13 +47,26 @@ class RuleExpressionService
|
|||||||
/**
|
/**
|
||||||
* Compile DSL expression to engine-compatible JSON structure.
|
* Compile DSL expression to engine-compatible JSON structure.
|
||||||
*
|
*
|
||||||
* Supported DSL:
|
* Supported DSL (canonical):
|
||||||
* - if(condition ? action : action)
|
* - if(condition; then-actions; else-actions)
|
||||||
* - sex('F'|'M') -> order["Sex"] == 'F'
|
* - sex('F'|'M') -> patient["Sex"] == 'F'
|
||||||
* - set_result(value) -> {"value": value} or {"valueExpr": "value"}
|
* - priority('R'|'S'|'U') -> order["Priority"] == 'S'
|
||||||
|
* - age > 18 -> age > 18
|
||||||
|
* - requested('CODE') -> requested('CODE') (resolved at runtime)
|
||||||
|
* - result_set(value)
|
||||||
|
* - test_insert('CODE')
|
||||||
|
* - test_delete('CODE')
|
||||||
|
* - comment_insert('text')
|
||||||
|
* - nothing
|
||||||
|
* - Multi-action: result_set(0.5):test_insert('CODE'):comment_insert('text')
|
||||||
|
*
|
||||||
|
* Backward compatible aliases:
|
||||||
|
* - set_result -> result_set
|
||||||
|
* - insert -> test_insert
|
||||||
|
* - add_comment -> comment_insert
|
||||||
*
|
*
|
||||||
* @param string $expr The raw DSL expression
|
* @param string $expr The raw DSL expression
|
||||||
* @return array The compiled structure with valueExpr
|
* @return array The compiled structure with actions
|
||||||
* @throws \InvalidArgumentException If DSL is invalid
|
* @throws \InvalidArgumentException If DSL is invalid
|
||||||
*/
|
*/
|
||||||
public function compile(string $expr): array
|
public function compile(string $expr): array
|
||||||
@ -63,109 +76,189 @@ class RuleExpressionService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove outer parentheses from if(...)
|
$inner = $this->extractIfInner($expr);
|
||||||
if (preg_match('/^if\s*\(\s*(.+?)\s*\)$/s', $expr, $m)) {
|
if ($inner === null) {
|
||||||
$expr = trim($m[1]);
|
// Allow callers to pass without if(...) wrapper
|
||||||
|
if (substr_count($expr, ';') >= 2) {
|
||||||
|
$inner = $expr;
|
||||||
|
} else {
|
||||||
|
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition; then; else)" format');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse: condition ? thenAction : elseAction
|
$parts = $this->splitTopLevel($inner, ';', 3);
|
||||||
if (!preg_match('/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/s', $expr, $parts)) {
|
if (count($parts) !== 3) {
|
||||||
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition ? action : action)" format');
|
// Fallback: legacy ternary syntax
|
||||||
|
$ternary = $this->convertTopLevelTernaryToSemicolon($inner);
|
||||||
|
if ($ternary !== null) {
|
||||||
|
$parts = $this->splitTopLevel($ternary, ';', 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$condition = trim($parts[1]);
|
if (count($parts) !== 3) {
|
||||||
$thenAction = trim($parts[2]);
|
throw new \InvalidArgumentException('Invalid DSL: expected exactly 3 parts "condition; then; else"');
|
||||||
$elseAction = trim($parts[3]);
|
}
|
||||||
|
|
||||||
|
$condition = trim($parts[0]);
|
||||||
|
$thenAction = trim($parts[1]);
|
||||||
|
$elseAction = trim($parts[2]);
|
||||||
|
|
||||||
// Compile condition
|
// Compile condition
|
||||||
$compiledCondition = $this->compileCondition($condition);
|
$compiledCondition = $this->compileCondition($condition);
|
||||||
|
|
||||||
// Compile actions
|
// Compile actions (supports multi-action with : separator)
|
||||||
$thenCompiled = $this->compileAction($thenAction);
|
$thenActions = $this->compileMultiAction($thenAction);
|
||||||
$elseCompiled = $this->compileAction($elseAction);
|
$elseActions = $this->compileMultiAction($elseAction);
|
||||||
|
|
||||||
// Build valueExpr combining condition and actions
|
// Build valueExpr for backward compatibility
|
||||||
$thenValue = $thenCompiled['valueExpr'] ?? json_encode($thenCompiled['value'] ?? null);
|
$thenValueExpr = $this->buildValueExpr($thenActions);
|
||||||
$elseValue = $elseCompiled['valueExpr'] ?? json_encode($elseCompiled['value'] ?? null);
|
$elseValueExpr = $this->buildValueExpr($elseActions);
|
||||||
|
|
||||||
// Handle string vs numeric values
|
$valueExpr = "({$compiledCondition}) ? {$thenValueExpr} : {$elseValueExpr}";
|
||||||
if (is_string($thenCompiled['value'] ?? null)) {
|
|
||||||
$thenValue = '"' . addslashes($thenCompiled['value']) . '"';
|
|
||||||
}
|
|
||||||
if (is_string($elseCompiled['value'] ?? null)) {
|
|
||||||
$elseValue = '"' . addslashes($elseCompiled['value']) . '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
$valueExpr = "({$compiledCondition}) ? {$thenValue} : {$elseValue}";
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'conditionExpr' => $compiledCondition,
|
'conditionExpr' => $compiledCondition,
|
||||||
'valueExpr' => $valueExpr,
|
'valueExpr' => $valueExpr,
|
||||||
'then' => $thenCompiled,
|
'then' => $thenActions,
|
||||||
'else' => $elseCompiled,
|
'else' => $elseActions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile DSL condition to ExpressionLanguage expression
|
* Compile DSL condition to ExpressionLanguage expression
|
||||||
|
* Supports: sex(), priority(), age, requested(), &&, ||, (), comparison operators
|
||||||
*/
|
*/
|
||||||
private function compileCondition(string $condition): string
|
private function compileCondition(string $condition): string
|
||||||
{
|
{
|
||||||
$condition = trim($condition);
|
$condition = trim($condition);
|
||||||
|
if ($condition === '') {
|
||||||
// sex('F') -> order["Sex"] == 'F'
|
return 'true';
|
||||||
if (preg_match("/^sex\s*\(\s*['\"]([MF])['\"]\s*\)$/i", $condition, $m)) {
|
|
||||||
return 'order["Sex"] == "' . $m[1] . '"';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sex == 'F' (alternative syntax)
|
$tokens = $this->tokenize($condition);
|
||||||
if (preg_match('/^\s*sex\s*==\s*[\'"]([MF])[\'"]\s*$/i', $condition, $m)) {
|
$out = '';
|
||||||
return 'order["Sex"] == "' . $m[1] . '"';
|
$count = count($tokens);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$t = $tokens[$i];
|
||||||
|
$type = $t['type'];
|
||||||
|
$val = $t['value'];
|
||||||
|
|
||||||
|
if ($type === 'op' && $val === '&&') {
|
||||||
|
$out .= ' and ';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($type === 'op' && $val === '||') {
|
||||||
|
$out .= ' or ';
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// priority('S') -> order["Priority"] == 'S'
|
if ($type === 'ident') {
|
||||||
if (preg_match("/^priority\s*\(\s*['\"]([SR])['\"]\s*\)$/i", $condition, $m)) {
|
$lower = strtolower($val);
|
||||||
return 'order["Priority"] == "' . $m[1] . '"';
|
|
||||||
|
// sex('M')
|
||||||
|
if ($lower === 'sex') {
|
||||||
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
||||||
|
if ($arg !== null) {
|
||||||
|
$out .= 'patient["Sex"] == "' . addslashes($arg['value']) . '"';
|
||||||
|
$i = $arg['endIndex'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// priority == 'S' (alternative syntax)
|
// priority('S')
|
||||||
if (preg_match('/^\s*priority\s*==\s*[\'"]([SR])[\'"]\s*$/i', $condition, $m)) {
|
if ($lower === 'priority') {
|
||||||
return 'order["Priority"] == "' . $m[1] . '"';
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
||||||
|
if ($arg !== null) {
|
||||||
|
$out .= 'order["Priority"] == "' . addslashes($arg['value']) . '"';
|
||||||
|
$i = $arg['endIndex'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// age > 18 -> patient["Age"] > 18 (if available) or order["Age"] > 18
|
// requested('CODE')
|
||||||
if (preg_match('/^\s*age\s*([<>]=?)\s*(\d+)\s*$/i', $condition, $m)) {
|
if ($lower === 'requested') {
|
||||||
return 'order["Age"] ' . $m[1] . ' ' . $m[2];
|
$arg = $this->parseSingleStringArg($tokens, $i + 1);
|
||||||
|
if ($arg !== null) {
|
||||||
|
$out .= 'requested("' . addslashes($arg['value']) . '")';
|
||||||
|
$i = $arg['endIndex'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already valid ExpressionLanguage, return as-is
|
// age >= 18
|
||||||
return $condition;
|
if ($lower === 'age') {
|
||||||
|
$op = $tokens[$i + 1] ?? null;
|
||||||
|
$num = $tokens[$i + 2] ?? null;
|
||||||
|
if (($op['type'] ?? '') === 'op' && in_array($op['value'], ['<', '>', '<=', '>=', '==', '!='], true) && ($num['type'] ?? '') === 'number') {
|
||||||
|
$out .= 'age ' . $op['value'] . ' ' . $num['value'];
|
||||||
|
$i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile DSL action to action params
|
* Compile multi-action string (separated by :)
|
||||||
|
* Returns array of action objects
|
||||||
*/
|
*/
|
||||||
private function compileAction(string $action): array
|
private function compileMultiAction(string $actionStr): array
|
||||||
|
{
|
||||||
|
$actionStr = trim($actionStr);
|
||||||
|
|
||||||
|
// Split by : separator (top-level only)
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
$parts = $this->splitTopLevel($actionStr, ':');
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$action = $this->compileSingleAction(trim($part));
|
||||||
|
if ($action !== null) {
|
||||||
|
$actions[] = $action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a single action
|
||||||
|
*/
|
||||||
|
private function compileSingleAction(string $action): ?array
|
||||||
{
|
{
|
||||||
$action = trim($action);
|
$action = trim($action);
|
||||||
|
|
||||||
// set_result(value) -> SET_RESULT action
|
// nothing - no operation
|
||||||
if (preg_match('/^set_result\s*\(\s*(.+?)\s*\)$/i', $action, $m)) {
|
if (strcasecmp($action, 'nothing') === 0) {
|
||||||
$value = trim($m[1]);
|
return [
|
||||||
|
'type' => 'NO_OP',
|
||||||
|
'value' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// result_set(value) [aliases: set_result]
|
||||||
|
if (preg_match('/^(result_set|set_result)\s*\(\s*(.+?)\s*\)$/is', $action, $m)) {
|
||||||
|
$value = trim($m[2]);
|
||||||
|
|
||||||
// Check if it's a number
|
// Check if it's a number
|
||||||
if (is_numeric($value)) {
|
if (is_numeric($value)) {
|
||||||
return [
|
return [
|
||||||
'type' => 'SET_RESULT',
|
'type' => 'RESULT_SET',
|
||||||
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
|
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
|
||||||
'valueExpr' => $value,
|
'valueExpr' => $value,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a quoted string
|
// Check if it's a quoted string (single or double quotes)
|
||||||
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
|
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
|
||||||
return [
|
return [
|
||||||
'type' => 'SET_RESULT',
|
'type' => 'RESULT_SET',
|
||||||
'value' => $vm[1],
|
'value' => $vm[1],
|
||||||
'valueExpr' => '"' . addslashes($vm[1]) . '"',
|
'valueExpr' => '"' . addslashes($vm[1]) . '"',
|
||||||
];
|
];
|
||||||
@ -173,11 +266,404 @@ class RuleExpressionService
|
|||||||
|
|
||||||
// Complex expression
|
// Complex expression
|
||||||
return [
|
return [
|
||||||
'type' => 'SET_RESULT',
|
'type' => 'RESULT_SET',
|
||||||
'valueExpr' => $value,
|
'valueExpr' => $value,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// test_insert('CODE') [aliases: insert]
|
||||||
|
if (preg_match('/^(test_insert|insert)\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) {
|
||||||
|
return [
|
||||||
|
'type' => 'TEST_INSERT',
|
||||||
|
'testCode' => $m[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// test_delete('CODE')
|
||||||
|
if (preg_match('/^test_delete\s*\(\s*["\']([^"\']+)["\']\s*\)$/i', $action, $m)) {
|
||||||
|
return [
|
||||||
|
'type' => 'TEST_DELETE',
|
||||||
|
'testCode' => $m[1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// comment_insert('text') [aliases: add_comment]
|
||||||
|
if (preg_match('/^(comment_insert|add_comment)\s*\(\s*["\'](.+?)["\']\s*\)$/is', $action, $m)) {
|
||||||
|
return [
|
||||||
|
'type' => 'COMMENT_INSERT',
|
||||||
|
'comment' => $m[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
throw new \InvalidArgumentException('Unknown action: ' . $action);
|
throw new \InvalidArgumentException('Unknown action: ' . $action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build valueExpr string from array of actions (for backward compatibility)
|
||||||
|
*/
|
||||||
|
private function buildValueExpr(array $actions): string
|
||||||
|
{
|
||||||
|
if (empty($actions)) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first SET_RESULT action's value, or null
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
if (($action['type'] ?? '') === 'RESULT_SET' || ($action['type'] ?? '') === 'SET_RESULT') {
|
||||||
|
if (isset($action['valueExpr'])) {
|
||||||
|
return $action['valueExpr'];
|
||||||
|
}
|
||||||
|
if (isset($action['value'])) {
|
||||||
|
if (is_string($action['value'])) {
|
||||||
|
return '"' . addslashes($action['value']) . '"';
|
||||||
|
}
|
||||||
|
return json_encode($action['value']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and split multi-rule expressions (comma-separated)
|
||||||
|
* Returns array of individual rule expressions
|
||||||
|
*/
|
||||||
|
public function parseMultiRule(string $expr): array
|
||||||
|
{
|
||||||
|
$expr = trim($expr);
|
||||||
|
if ($expr === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [];
|
||||||
|
$depth = 0;
|
||||||
|
$current = '';
|
||||||
|
$len = strlen($expr);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$char = $expr[$i];
|
||||||
|
|
||||||
|
if ($char === '(') {
|
||||||
|
$depth++;
|
||||||
|
} elseif ($char === ')') {
|
||||||
|
$depth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === ',' && $depth === 0) {
|
||||||
|
$trimmed = trim($current);
|
||||||
|
if ($trimmed !== '') {
|
||||||
|
$rules[] = $trimmed;
|
||||||
|
}
|
||||||
|
$current = '';
|
||||||
|
} else {
|
||||||
|
$current .= $char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last rule
|
||||||
|
$trimmed = trim($current);
|
||||||
|
if ($trimmed !== '') {
|
||||||
|
$rules[] = $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractIfInner(string $expr): ?string
|
||||||
|
{
|
||||||
|
$expr = trim($expr);
|
||||||
|
if (!preg_match('/^if\s*\(/i', $expr)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pos = stripos($expr, '(');
|
||||||
|
if ($pos === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $pos + 1;
|
||||||
|
$end = $this->findMatchingParen($expr, $pos);
|
||||||
|
if ($end === null) {
|
||||||
|
throw new \InvalidArgumentException('Invalid DSL: unbalanced parentheses in if(...)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow trailing whitespace after the closing paren
|
||||||
|
if (trim(substr($expr, $end + 1)) !== '') {
|
||||||
|
throw new \InvalidArgumentException('Invalid DSL: unexpected trailing characters after if(...)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(substr($expr, $start, $end - $start));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findMatchingParen(string $s, int $openIndex): ?int
|
||||||
|
{
|
||||||
|
$len = strlen($s);
|
||||||
|
$depth = 0;
|
||||||
|
$inSingle = false;
|
||||||
|
$inDouble = false;
|
||||||
|
|
||||||
|
for ($i = $openIndex; $i < $len; $i++) {
|
||||||
|
$ch = $s[$i];
|
||||||
|
|
||||||
|
if ($ch === '\\') {
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$inDouble && $ch === "'") {
|
||||||
|
$inSingle = !$inSingle;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$inSingle && $ch === '"') {
|
||||||
|
$inDouble = !$inDouble;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($inSingle || $inDouble) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ch === '(') {
|
||||||
|
$depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ch === ')') {
|
||||||
|
$depth--;
|
||||||
|
if ($depth === 0) {
|
||||||
|
return $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split string by a delimiter only when not inside quotes/parentheses.
|
||||||
|
* If $limit is provided, splits into at most $limit parts.
|
||||||
|
*/
|
||||||
|
private function splitTopLevel(string $s, string $delimiter, ?int $limit = null): array
|
||||||
|
{
|
||||||
|
$s = (string) $s;
|
||||||
|
$len = strlen($s);
|
||||||
|
$depth = 0;
|
||||||
|
$inSingle = false;
|
||||||
|
$inDouble = false;
|
||||||
|
$parts = [];
|
||||||
|
$buf = '';
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$ch = $s[$i];
|
||||||
|
|
||||||
|
if ($ch === '\\') {
|
||||||
|
$buf .= $ch;
|
||||||
|
if ($i + 1 < $len) {
|
||||||
|
$buf .= $s[$i + 1];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$inDouble && $ch === "'") {
|
||||||
|
$inSingle = !$inSingle;
|
||||||
|
$buf .= $ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$inSingle && $ch === '"') {
|
||||||
|
$inDouble = !$inDouble;
|
||||||
|
$buf .= $ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$inSingle && !$inDouble) {
|
||||||
|
if ($ch === '(') {
|
||||||
|
$depth++;
|
||||||
|
} elseif ($ch === ')') {
|
||||||
|
$depth = max(0, $depth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($depth === 0 && $ch === $delimiter) {
|
||||||
|
$parts[] = trim($buf);
|
||||||
|
$buf = '';
|
||||||
|
if ($limit !== null && count($parts) >= ($limit - 1)) {
|
||||||
|
$buf .= substr($s, $i + 1);
|
||||||
|
$i = $len;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$buf .= $ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = trim($buf);
|
||||||
|
$parts = array_values(array_filter($parts, static fn ($p) => $p !== ''));
|
||||||
|
return $parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a top-level ternary (condition ? then : else) into semicolon form.
|
||||||
|
*/
|
||||||
|
private function convertTopLevelTernaryToSemicolon(string $s): ?string
|
||||||
|
{
|
||||||
|
$len = strlen($s);
|
||||||
|
$depth = 0;
|
||||||
|
$inSingle = false;
|
||||||
|
$inDouble = false;
|
||||||
|
$qPos = null;
|
||||||
|
$cPos = null;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$ch = $s[$i];
|
||||||
|
if ($ch === '\\') {
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$inDouble && $ch === "'") {
|
||||||
|
$inSingle = !$inSingle;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$inSingle && $ch === '"') {
|
||||||
|
$inDouble = !$inDouble;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($inSingle || $inDouble) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ch === '(') {
|
||||||
|
$depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ch === ')') {
|
||||||
|
$depth = max(0, $depth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($depth === 0 && $ch === '?' && $qPos === null) {
|
||||||
|
$qPos = $i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($depth === 0 && $ch === ':' && $qPos !== null) {
|
||||||
|
$cPos = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($qPos === null || $cPos === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cond = trim(substr($s, 0, $qPos));
|
||||||
|
$then = trim(substr($s, $qPos + 1, $cPos - $qPos - 1));
|
||||||
|
$else = trim(substr($s, $cPos + 1));
|
||||||
|
|
||||||
|
if ($cond === '' || $then === '' || $else === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cond . '; ' . $then . '; ' . $else;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a condition for lightweight transforms.
|
||||||
|
* Returns tokens with keys: type (ident|number|string|op|punct|other), value.
|
||||||
|
*/
|
||||||
|
private function tokenize(string $s): array
|
||||||
|
{
|
||||||
|
$len = strlen($s);
|
||||||
|
$tokens = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$ch = $s[$i];
|
||||||
|
|
||||||
|
if (ctype_space($ch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$two = ($i + 1 < $len) ? $ch . $s[$i + 1] : '';
|
||||||
|
if (in_array($two, ['&&', '||', '>=', '<=', '==', '!='], true)) {
|
||||||
|
$tokens[] = ['type' => 'op', 'value' => $two];
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($ch, ['>', '<', '(', ')', '[', ']', ',', '+', '-', '*', '/', '%', '!', '.', ':', '?'], true)) {
|
||||||
|
$tokens[] = ['type' => ($ch === '>' || $ch === '<' || $ch === '!' ? 'op' : 'punct'), 'value' => $ch];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ch === '"' || $ch === "'") {
|
||||||
|
$quote = $ch;
|
||||||
|
$buf = $quote;
|
||||||
|
$i++;
|
||||||
|
for (; $i < $len; $i++) {
|
||||||
|
$c = $s[$i];
|
||||||
|
$buf .= $c;
|
||||||
|
if ($c === '\\' && $i + 1 < $len) {
|
||||||
|
$buf .= $s[$i + 1];
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($c === $quote) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$tokens[] = ['type' => 'string', 'value' => $buf];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit($ch)) {
|
||||||
|
$buf = $ch;
|
||||||
|
while ($i + 1 < $len && (ctype_digit($s[$i + 1]) || $s[$i + 1] === '.')) {
|
||||||
|
$buf .= $s[$i + 1];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
$tokens[] = ['type' => 'number', 'value' => $buf];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_alpha($ch) || $ch === '_') {
|
||||||
|
$buf = $ch;
|
||||||
|
while ($i + 1 < $len && (ctype_alnum($s[$i + 1]) || $s[$i + 1] === '_')) {
|
||||||
|
$buf .= $s[$i + 1];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
$tokens[] = ['type' => 'ident', 'value' => $buf];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens[] = ['type' => 'other', 'value' => $ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single quoted string argument from tokens starting at $start.
|
||||||
|
* Expects: '(' string ')'. Returns ['value' => string, 'endIndex' => int] or null.
|
||||||
|
*/
|
||||||
|
private function parseSingleStringArg(array $tokens, int $start): ?array
|
||||||
|
{
|
||||||
|
$t0 = $tokens[$start] ?? null;
|
||||||
|
$t1 = $tokens[$start + 1] ?? null;
|
||||||
|
$t2 = $tokens[$start + 2] ?? null;
|
||||||
|
if (($t0['value'] ?? null) !== '(') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (($t1['type'] ?? null) !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (($t2['value'] ?? null) !== ')') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $t1['value'];
|
||||||
|
$quote = $raw[0] ?? '';
|
||||||
|
if ($quote !== '"' && $quote !== "'") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$val = substr($raw, 1, -1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'value' => $val,
|
||||||
|
'endIndex' => $start + 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
280
docs/test-rule-engine.md
Normal file
280
docs/test-rule-engine.md
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
# 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/rules/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/rules/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)` | Write to `patres.Result` for the current order/test using the provided value | `result_set(0.5)` |
|
||||||
|
| `test_insert('CODE')` | Insert a test row by `TestSiteCode` if it doesn’t already exist for the order | `test_insert('HBA1C')` |
|
||||||
|
| `test_delete('CODE')` | Remove a previously requested test from the current order when the rule deems it unnecessary | `test_delete('INS')` |
|
||||||
|
| `comment_insert('text')` | Insert an order comment (`ordercom`) describing priority or clinical guidance | `comment_insert('Male patient - review')` |
|
||||||
|
| `nothing` | Explicit no-op to terminate an action chain | `nothing` |
|
||||||
|
|
||||||
|
> **Note:** `set_priority()` was removed. Use `comment_insert()` for priority notes without altering billing.
|
||||||
|
|
||||||
|
## Runtime Requirements
|
||||||
|
|
||||||
|
1. **Compiled expression required:** Rules without `ConditionExprCompiled` are ignored (see `RuleEngineService::run`).
|
||||||
|
2. **Order context:** `context.order.InternalOID` must exist for any action that writes to `patres` or `ordercom`.
|
||||||
|
3. **TestSiteID:** `result_set()` needs `testSiteID` (either provided in context or from `order.TestSiteID`). `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(0.5); result_set(0.6))
|
||||||
|
```
|
||||||
|
Returns `0.5` for males, `0.6` otherwise.
|
||||||
|
|
||||||
|
```
|
||||||
|
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 Usage
|
||||||
|
|
||||||
|
### Compile DSL
|
||||||
|
|
||||||
|
Validates the DSL and returns a compiled JSON structure that should be persisted in `ConditionExprCompiled`.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/rules/compile
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"expr": "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; store the JSON payload in `ConditionExprCompiled` before saving the rule.
|
||||||
|
|
||||||
|
### Evaluate Expression (Validation)
|
||||||
|
|
||||||
|
This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/rules/validate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"expr": "order[\"Age\"] > 18",
|
||||||
|
"context": {
|
||||||
|
"order": {
|
||||||
|
"Age": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Rule (example)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/rules
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/rules/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/rules/compile` to validate the DSL and view errors.
|
||||||
|
|
||||||
|
### Invalid Expression
|
||||||
|
|
||||||
|
1. POST the expression to `/api/rules/compile` to get a detailed compilation error.
|
||||||
|
2. If using `/api/rules/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.
|
||||||
@ -3156,7 +3156,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
example: ORDER_CREATED
|
example: test_created
|
||||||
TestSiteIDs:
|
TestSiteIDs:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -3169,19 +3169,12 @@ paths:
|
|||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: order["Priority"] == "S"
|
description: Raw DSL expression. Compile it first and persist the compiled JSON in ConditionExprCompiled.
|
||||||
actions:
|
example: if(sex('M'); result_set(0.5); result_set(0.6))
|
||||||
type: array
|
ConditionExprCompiled:
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType:
|
|
||||||
type: string
|
type: string
|
||||||
example: SET_RESULT
|
nullable: true
|
||||||
ActionParams:
|
description: Compiled JSON payload from POST /api/rules/compile
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
required:
|
required:
|
||||||
- RuleCode
|
- RuleCode
|
||||||
- RuleName
|
- RuleName
|
||||||
@ -3194,7 +3187,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Rules
|
- Rules
|
||||||
summary: Get rule with actions and linked tests
|
summary: Get rule with linked tests
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -3206,7 +3199,7 @@ paths:
|
|||||||
description: RuleID
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule details with actions and linked test sites
|
description: Rule details with linked test sites
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -3328,7 +3321,7 @@ paths:
|
|||||||
expr:
|
expr:
|
||||||
type: string
|
type: string
|
||||||
description: Raw DSL expression
|
description: Raw DSL expression
|
||||||
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
|
example: if(sex('M'); result_set(0.5); result_set(0.6))
|
||||||
required:
|
required:
|
||||||
- expr
|
- expr
|
||||||
responses:
|
responses:
|
||||||
@ -3356,126 +3349,6 @@ paths:
|
|||||||
description: JSON string to save to ConditionExprCompiled field
|
description: JSON string to save to ConditionExprCompiled field
|
||||||
'400':
|
'400':
|
||||||
description: Compilation failed (invalid syntax)
|
description: Compilation failed (invalid syntax)
|
||||||
/api/rules/{id}/actions:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Rules
|
|
||||||
summary: List actions for a rule
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Actions list
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
data:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/RuleAction'
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- Rules
|
|
||||||
summary: Create action for a rule
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType:
|
|
||||||
type: string
|
|
||||||
example: SET_RESULT
|
|
||||||
ActionParams:
|
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
required:
|
|
||||||
- ActionType
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Action created
|
|
||||||
/api/rules/{id}/actions/{actionId}:
|
|
||||||
patch:
|
|
||||||
tags:
|
|
||||||
- Rules
|
|
||||||
summary: Update action
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
- name: actionId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleActionID
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType:
|
|
||||||
type: string
|
|
||||||
ActionParams:
|
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Action updated
|
|
||||||
delete:
|
|
||||||
tags:
|
|
||||||
- Rules
|
|
||||||
summary: Soft delete action
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
- name: actionId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleActionID
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Action deleted
|
|
||||||
/api/specimen:
|
/api/specimen:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -7385,12 +7258,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Raw DSL expression (editable)
|
description: Raw DSL expression (editable)
|
||||||
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
|
example: if(sex('M'); result_set(0.5); result_set(0.6))
|
||||||
ConditionExprCompiled:
|
ConditionExprCompiled:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
||||||
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
|
example: '{"conditionExpr":"patient[\"Sex\"] == \"M\"","valueExpr":"(patient[\"Sex\"] == \"M\") ? 0.5 : 0.6","then":[{"type":"RESULT_SET","value":0.5,"valueExpr":"0.5"}],"else":[{"type":"RESULT_SET","value":0.6,"valueExpr":"0.6"}]}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -7403,38 +7276,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
RuleAction:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
RuleActionID:
|
|
||||||
type: integer
|
|
||||||
RuleID:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
|
||||||
type: string
|
|
||||||
example: SET_RESULT
|
|
||||||
ActionParams:
|
|
||||||
type: string
|
|
||||||
description: JSON string parameters
|
|
||||||
nullable: true
|
|
||||||
example: '{"testSiteID": 1, "value": "Normal"}'
|
|
||||||
CreateDate:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
EndDate:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
RuleWithDetails:
|
RuleWithDetails:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/RuleDef'
|
- $ref: '#/components/schemas/RuleDef'
|
||||||
- type: object
|
- type: object
|
||||||
properties:
|
properties:
|
||||||
actions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/RuleAction'
|
|
||||||
linkedTests:
|
linkedTests:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
@ -191,8 +191,6 @@ components:
|
|||||||
# Rules schemas
|
# Rules schemas
|
||||||
RuleDef:
|
RuleDef:
|
||||||
$ref: './components/schemas/rules.yaml#/RuleDef'
|
$ref: './components/schemas/rules.yaml#/RuleDef'
|
||||||
RuleAction:
|
|
||||||
$ref: './components/schemas/rules.yaml#/RuleAction'
|
|
||||||
RuleWithDetails:
|
RuleWithDetails:
|
||||||
$ref: './components/schemas/rules.yaml#/RuleWithDetails'
|
$ref: './components/schemas/rules.yaml#/RuleWithDetails'
|
||||||
TestRule:
|
TestRule:
|
||||||
|
|||||||
@ -69,10 +69,14 @@ console.log(`\n[SUCCESS] Merged paths into: ${tempFile}`);
|
|||||||
// Now use Redocly CLI to resolve all $ref references
|
// Now use Redocly CLI to resolve all $ref references
|
||||||
console.log('\nResolving $ref references with Redocly CLI...');
|
console.log('\nResolving $ref references with Redocly CLI...');
|
||||||
try {
|
try {
|
||||||
execSync(`redocly bundle "${tempFile}" -o "${outputFile}"`, {
|
const runBundle = (cmd) => execSync(cmd, { cwd: publicDir, stdio: 'inherit' });
|
||||||
cwd: publicDir,
|
|
||||||
stdio: 'inherit'
|
try {
|
||||||
});
|
runBundle(`redocly bundle "${tempFile}" -o "${outputFile}"`);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: use npx if redocly isn't installed globally
|
||||||
|
runBundle(`npx --yes @redocly/cli bundle "${tempFile}" -o "${outputFile}"`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n[SUCCESS] Resolved all $ref references to: ${outputFile}`);
|
console.log(`\n[SUCCESS] Resolved all $ref references to: ${outputFile}`);
|
||||||
|
|
||||||
@ -91,6 +95,6 @@ try {
|
|||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`\n[ERROR] Failed to run Redocly CLI: ${e.message}`);
|
console.error(`\n[ERROR] Failed to run Redocly CLI: ${e.message}`);
|
||||||
console.error('Make sure Redocly CLI is installed: npm install -g @redocly/cli');
|
console.error('Make sure Redocly CLI is installed (global or via npx): npm install -g @redocly/cli');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,12 +19,12 @@ RuleDef:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Raw DSL expression (editable)
|
description: Raw DSL expression (editable)
|
||||||
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
|
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||||
ConditionExprCompiled:
|
ConditionExprCompiled:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
||||||
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
|
example: '{"conditionExpr":"patient[\"Sex\"] == \"M\"","valueExpr":"(patient[\"Sex\"] == \"M\") ? 0.5 : 0.6","then":[{"type":"RESULT_SET","value":0.5,"valueExpr":"0.5"}],"else":[{"type":"RESULT_SET","value":0.6,"valueExpr":"0.6"}]}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -38,39 +38,11 @@ RuleDef:
|
|||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
|
|
||||||
RuleAction:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
RuleActionID:
|
|
||||||
type: integer
|
|
||||||
RuleID:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
|
||||||
type: string
|
|
||||||
example: SET_RESULT
|
|
||||||
ActionParams:
|
|
||||||
type: string
|
|
||||||
description: JSON string parameters
|
|
||||||
nullable: true
|
|
||||||
example: '{"testSiteID": 1, "value": "Normal"}'
|
|
||||||
CreateDate:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
EndDate:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
|
|
||||||
RuleWithDetails:
|
RuleWithDetails:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: './rules.yaml#/RuleDef'
|
- $ref: './rules.yaml#/RuleDef'
|
||||||
- type: object
|
- type: object
|
||||||
properties:
|
properties:
|
||||||
actions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: './rules.yaml#/RuleAction'
|
|
||||||
linkedTests:
|
linkedTests:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
@ -62,7 +62,7 @@
|
|||||||
type: string
|
type: string
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
example: ORDER_CREATED
|
example: test_created
|
||||||
TestSiteIDs:
|
TestSiteIDs:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -72,19 +72,12 @@
|
|||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: 'order["Priority"] == "S"'
|
description: Raw DSL expression. Compile it first and persist the compiled JSON in ConditionExprCompiled.
|
||||||
actions:
|
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||||
type: array
|
ConditionExprCompiled:
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType:
|
|
||||||
type: string
|
type: string
|
||||||
example: SET_RESULT
|
nullable: true
|
||||||
ActionParams:
|
description: Compiled JSON payload from POST /api/rules/compile
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
required: [RuleCode, RuleName, EventCode, TestSiteIDs]
|
required: [RuleCode, RuleName, EventCode, TestSiteIDs]
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
@ -93,7 +86,7 @@
|
|||||||
/api/rules/{id}:
|
/api/rules/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Get rule with actions and linked tests
|
summary: Get rule with linked tests
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -105,7 +98,7 @@
|
|||||||
description: RuleID
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule details with actions and linked test sites
|
description: Rule details with linked test sites
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -220,7 +213,7 @@
|
|||||||
expr:
|
expr:
|
||||||
type: string
|
type: string
|
||||||
description: Raw DSL expression
|
description: Raw DSL expression
|
||||||
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
|
example: "if(sex('M'); result_set(0.5); result_set(0.6))"
|
||||||
required: [expr]
|
required: [expr]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@ -247,121 +240,3 @@
|
|||||||
description: JSON string to save to ConditionExprCompiled field
|
description: JSON string to save to ConditionExprCompiled field
|
||||||
'400':
|
'400':
|
||||||
description: Compilation failed (invalid syntax)
|
description: Compilation failed (invalid syntax)
|
||||||
|
|
||||||
/api/rules/{id}/actions:
|
|
||||||
get:
|
|
||||||
tags: [Rules]
|
|
||||||
summary: List actions for a rule
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Actions list
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
data:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '../components/schemas/rules.yaml#/RuleAction'
|
|
||||||
|
|
||||||
post:
|
|
||||||
tags: [Rules]
|
|
||||||
summary: Create action for a rule
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType:
|
|
||||||
type: string
|
|
||||||
example: SET_RESULT
|
|
||||||
ActionParams:
|
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
required: [ActionType]
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Action created
|
|
||||||
|
|
||||||
/api/rules/{id}/actions/{actionId}:
|
|
||||||
patch:
|
|
||||||
tags: [Rules]
|
|
||||||
summary: Update action
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
- name: actionId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleActionID
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ActionType: { type: string }
|
|
||||||
ActionParams:
|
|
||||||
oneOf:
|
|
||||||
- type: string
|
|
||||||
- type: object
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Action updated
|
|
||||||
|
|
||||||
delete:
|
|
||||||
tags: [Rules]
|
|
||||||
summary: Soft delete action
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleID
|
|
||||||
- name: actionId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
description: RuleActionID
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Action deleted
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
*/
|
*/
|
||||||
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
|
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
|
||||||
{
|
{
|
||||||
$rules = $this->model->getActiveByEvent('ORDER_CREATED', null);
|
$rules = $this->model->getActiveByEvent('test_created', null);
|
||||||
|
|
||||||
$this->assertIsArray($rules);
|
$this->assertIsArray($rules);
|
||||||
$this->assertEmpty($rules);
|
$this->assertEmpty($rules);
|
||||||
@ -56,7 +56,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$ruleData = [
|
$ruleData = [
|
||||||
'RuleCode' => 'MULTI_TEST_RULE',
|
'RuleCode' => 'MULTI_TEST_RULE',
|
||||||
'RuleName' => 'Multi Test Rule',
|
'RuleName' => 'Multi Test Rule',
|
||||||
'EventCode' => 'ORDER_CREATED',
|
'EventCode' => 'test_created',
|
||||||
'ConditionExpr' => 'order["InternalOID"] > 0',
|
'ConditionExpr' => 'order["InternalOID"] > 0',
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@ -69,12 +69,12 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$this->model->linkTest($ruleID, $testSiteID2);
|
$this->model->linkTest($ruleID, $testSiteID2);
|
||||||
|
|
||||||
// Verify rule is returned for both test sites
|
// Verify rule is returned for both test sites
|
||||||
$rules1 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1);
|
$rules1 = $this->model->getActiveByEvent('test_created', $testSiteID1);
|
||||||
$this->assertNotEmpty($rules1);
|
$this->assertNotEmpty($rules1);
|
||||||
$this->assertCount(1, $rules1);
|
$this->assertCount(1, $rules1);
|
||||||
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
|
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
|
||||||
|
|
||||||
$rules2 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2);
|
$rules2 = $this->model->getActiveByEvent('test_created', $testSiteID2);
|
||||||
$this->assertNotEmpty($rules2);
|
$this->assertNotEmpty($rules2);
|
||||||
$this->assertCount(1, $rules2);
|
$this->assertCount(1, $rules2);
|
||||||
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
|
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
|
||||||
@ -107,7 +107,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$ruleData = [
|
$ruleData = [
|
||||||
'RuleCode' => 'UNLINKED_RULE',
|
'RuleCode' => 'UNLINKED_RULE',
|
||||||
'RuleName' => 'Unlinked Test Rule',
|
'RuleName' => 'Unlinked Test Rule',
|
||||||
'EventCode' => 'ORDER_CREATED',
|
'EventCode' => 'test_created',
|
||||||
'ConditionExpr' => 'true',
|
'ConditionExpr' => 'true',
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@ -116,14 +116,14 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$this->assertNotFalse($ruleID);
|
$this->assertNotFalse($ruleID);
|
||||||
|
|
||||||
// Verify rule is NOT returned when not linked
|
// Verify rule is NOT returned when not linked
|
||||||
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
|
||||||
$this->assertEmpty($rules);
|
$this->assertEmpty($rules);
|
||||||
|
|
||||||
// Now link the rule
|
// Now link the rule
|
||||||
$this->model->linkTest($ruleID, $testSiteID);
|
$this->model->linkTest($ruleID, $testSiteID);
|
||||||
|
|
||||||
// Verify rule is now returned
|
// Verify rule is now returned
|
||||||
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
|
||||||
$this->assertNotEmpty($rules);
|
$this->assertNotEmpty($rules);
|
||||||
$this->assertCount(1, $rules);
|
$this->assertCount(1, $rules);
|
||||||
|
|
||||||
@ -156,7 +156,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$ruleData = [
|
$ruleData = [
|
||||||
'RuleCode' => 'UNLINK_TEST',
|
'RuleCode' => 'UNLINK_TEST',
|
||||||
'RuleName' => 'Unlink Test Rule',
|
'RuleName' => 'Unlink Test Rule',
|
||||||
'EventCode' => 'ORDER_CREATED',
|
'EventCode' => 'test_created',
|
||||||
'ConditionExpr' => 'true',
|
'ConditionExpr' => 'true',
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@ -166,15 +166,15 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$this->model->linkTest($ruleID, $testSiteID2);
|
$this->model->linkTest($ruleID, $testSiteID2);
|
||||||
|
|
||||||
// Verify rule is returned for both
|
// Verify rule is returned for both
|
||||||
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
|
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
|
||||||
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
|
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
|
||||||
|
|
||||||
// Unlink from first test
|
// Unlink from first test
|
||||||
$this->model->unlinkTest($ruleID, $testSiteID1);
|
$this->model->unlinkTest($ruleID, $testSiteID1);
|
||||||
|
|
||||||
// Verify rule is NOT returned for first test but still for second
|
// Verify rule is NOT returned for first test but still for second
|
||||||
$this->assertEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
|
$this->assertEmpty($this->model->getActiveByEvent('test_created', $testSiteID1));
|
||||||
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
|
$this->assertNotEmpty($this->model->getActiveByEvent('test_created', $testSiteID2));
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
$this->model->delete($ruleID);
|
$this->model->delete($ruleID);
|
||||||
@ -203,7 +203,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$ruleData = [
|
$ruleData = [
|
||||||
'RuleCode' => 'DELETED_RULE',
|
'RuleCode' => 'DELETED_RULE',
|
||||||
'RuleName' => 'Deleted Test Rule',
|
'RuleName' => 'Deleted Test Rule',
|
||||||
'EventCode' => 'ORDER_CREATED',
|
'EventCode' => 'test_created',
|
||||||
'ConditionExpr' => 'true',
|
'ConditionExpr' => 'true',
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@ -213,14 +213,14 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$this->model->linkTest($ruleID, $testSiteID);
|
$this->model->linkTest($ruleID, $testSiteID);
|
||||||
|
|
||||||
// Verify rule is returned
|
// Verify rule is returned
|
||||||
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
|
||||||
$this->assertNotEmpty($rules);
|
$this->assertNotEmpty($rules);
|
||||||
|
|
||||||
// Soft delete the rule
|
// Soft delete the rule
|
||||||
$this->model->delete($ruleID);
|
$this->model->delete($ruleID);
|
||||||
|
|
||||||
// Verify deleted rule is NOT returned
|
// Verify deleted rule is NOT returned
|
||||||
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
$rules = $this->model->getActiveByEvent('test_created', $testSiteID);
|
||||||
$this->assertEmpty($rules);
|
$this->assertEmpty($rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,7 +249,7 @@ class RuleDefModelTest extends CIUnitTestCase
|
|||||||
$ruleData = [
|
$ruleData = [
|
||||||
'RuleCode' => 'LINKED_TESTS',
|
'RuleCode' => 'LINKED_TESTS',
|
||||||
'RuleName' => 'Linked Tests Rule',
|
'RuleName' => 'Linked Tests Rule',
|
||||||
'EventCode' => 'ORDER_CREATED',
|
'EventCode' => 'test_created',
|
||||||
'ConditionExpr' => 'true',
|
'ConditionExpr' => 'true',
|
||||||
'CreateDate' => date('Y-m-d H:i:s'),
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
|
|||||||
355
tests/unit/Rules/RuleEngineMultiActionTest.php
Normal file
355
tests/unit/Rules/RuleEngineMultiActionTest.php
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Rules;
|
||||||
|
|
||||||
|
use App\Services\RuleEngineService;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\DatabaseTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for Rule Engine with multi-action support
|
||||||
|
*/
|
||||||
|
class RuleEngineMultiActionTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use DatabaseTestTrait;
|
||||||
|
|
||||||
|
protected RuleEngineService $engine;
|
||||||
|
protected $seed = [];
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->engine = new RuleEngineService();
|
||||||
|
|
||||||
|
// Seed test data
|
||||||
|
$this->seedTestData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTestData(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Insert test testdefsite
|
||||||
|
$db->table('testdefsite')->insert([
|
||||||
|
'TestSiteCode' => 'GLU',
|
||||||
|
'TestSiteName' => 'Glucose',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db->table('testdefsite')->insert([
|
||||||
|
'TestSiteCode' => 'HBA1C',
|
||||||
|
'TestSiteName' => 'HbA1c',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Insert test order
|
||||||
|
$db->table('orders')->insert([
|
||||||
|
'OrderID' => 'ORD001',
|
||||||
|
'Sex' => 'M',
|
||||||
|
'Age' => 45,
|
||||||
|
'Priority' => 'R',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteSetResult()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
|
||||||
|
// Get test site ID
|
||||||
|
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
|
||||||
|
$testSiteID = $testSite['TestSiteID'] ?? null;
|
||||||
|
|
||||||
|
$this->assertNotNull($internalOID);
|
||||||
|
$this->assertNotNull($testSiteID);
|
||||||
|
|
||||||
|
// Insert initial patres row
|
||||||
|
$db->table('patres')->insert([
|
||||||
|
'OrderID' => $internalOID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Execute RESULT_SET action
|
||||||
|
$action = [
|
||||||
|
'type' => 'RESULT_SET',
|
||||||
|
'value' => 5.5,
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
'Sex' => 'M',
|
||||||
|
'Age' => 45,
|
||||||
|
],
|
||||||
|
'testSiteID' => $testSiteID,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
|
||||||
|
// Verify result was set
|
||||||
|
$patres = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('DelDate', null)
|
||||||
|
->get()->getRowArray();
|
||||||
|
|
||||||
|
$this->assertNotNull($patres);
|
||||||
|
$this->assertEquals(5.5, $patres['Result']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteInsertTest()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
|
||||||
|
$this->assertNotNull($internalOID);
|
||||||
|
|
||||||
|
// Execute TEST_INSERT action
|
||||||
|
$action = [
|
||||||
|
'type' => 'TEST_INSERT',
|
||||||
|
'testCode' => 'HBA1C',
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
|
||||||
|
// Verify test was inserted
|
||||||
|
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
|
||||||
|
$testSiteID = $testSite['TestSiteID'] ?? null;
|
||||||
|
|
||||||
|
$patres = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('DelDate', null)
|
||||||
|
->get()->getRowArray();
|
||||||
|
|
||||||
|
$this->assertNotNull($patres);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteAddComment()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
|
||||||
|
$this->assertNotNull($internalOID);
|
||||||
|
|
||||||
|
// Execute COMMENT_INSERT action
|
||||||
|
$action = [
|
||||||
|
'type' => 'COMMENT_INSERT',
|
||||||
|
'comment' => 'Test comment from rule engine',
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
|
||||||
|
// Verify comment was added
|
||||||
|
$comment = $db->table('ordercom')
|
||||||
|
->where('InternalOID', $internalOID)
|
||||||
|
->where('Comment', 'Test comment from rule engine')
|
||||||
|
->get()->getRowArray();
|
||||||
|
|
||||||
|
$this->assertNotNull($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteNoOp()
|
||||||
|
{
|
||||||
|
// Execute NO_OP action - should not throw or do anything
|
||||||
|
$action = [
|
||||||
|
'type' => 'NO_OP',
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => 1,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Should not throw exception
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
|
||||||
|
// Test passes if no exception is thrown
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiActionExecution()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
|
||||||
|
// Get test site ID
|
||||||
|
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
|
||||||
|
$testSiteID = $testSite['TestSiteID'] ?? null;
|
||||||
|
|
||||||
|
$this->assertNotNull($internalOID);
|
||||||
|
$this->assertNotNull($testSiteID);
|
||||||
|
|
||||||
|
// Insert initial patres row
|
||||||
|
$db->table('patres')->insert([
|
||||||
|
'OrderID' => $internalOID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Execute multiple actions
|
||||||
|
$actions = [
|
||||||
|
[
|
||||||
|
'type' => 'RESULT_SET',
|
||||||
|
'value' => 7.2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'COMMENT_INSERT',
|
||||||
|
'comment' => 'Multi-action test',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'TEST_INSERT',
|
||||||
|
'testCode' => 'HBA1C',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
],
|
||||||
|
'testSiteID' => $testSiteID,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SET_RESULT
|
||||||
|
$patres = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('DelDate', null)
|
||||||
|
->get()->getRowArray();
|
||||||
|
$this->assertEquals(7.2, $patres['Result']);
|
||||||
|
|
||||||
|
// Verify ADD_COMMENT
|
||||||
|
$comment = $db->table('ordercom')
|
||||||
|
->where('InternalOID', $internalOID)
|
||||||
|
->where('Comment', 'Multi-action test')
|
||||||
|
->get()->getRowArray();
|
||||||
|
$this->assertNotNull($comment);
|
||||||
|
|
||||||
|
// Verify INSERT_TEST
|
||||||
|
$hba1cSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
|
||||||
|
$hba1cPatres = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $hba1cSite['TestSiteID'])
|
||||||
|
->where('DelDate', null)
|
||||||
|
->get()->getRowArray();
|
||||||
|
$this->assertNotNull($hba1cPatres);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsTestRequested()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get order ID
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
|
||||||
|
// Get test site ID
|
||||||
|
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'GLU')->get()->getRowArray();
|
||||||
|
$testSiteID = $testSite['TestSiteID'] ?? null;
|
||||||
|
|
||||||
|
// Insert patres row for GLU test
|
||||||
|
$db->table('patres')->insert([
|
||||||
|
'OrderID' => $internalOID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test isTestRequested method
|
||||||
|
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['GLU', [
|
||||||
|
'order' => ['InternalOID' => $internalOID],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
|
||||||
|
// Test for non-existent test
|
||||||
|
$result = $this->invokeMethod($this->engine, 'isTestRequested', ['NONEXISTENT', [
|
||||||
|
'order' => ['InternalOID' => $internalOID],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExecuteDeleteTest(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
$order = $db->table('orders')->where('OrderID', 'ORD001')->get()->getRowArray();
|
||||||
|
$internalOID = $order['InternalOID'] ?? null;
|
||||||
|
$this->assertNotNull($internalOID);
|
||||||
|
|
||||||
|
$testSite = $db->table('testdefsite')->where('TestSiteCode', 'HBA1C')->get()->getRowArray();
|
||||||
|
$testSiteID = $testSite['TestSiteID'] ?? null;
|
||||||
|
$this->assertNotNull($testSiteID);
|
||||||
|
|
||||||
|
// Insert a patres row to delete
|
||||||
|
$db->table('patres')->insert([
|
||||||
|
'OrderID' => $internalOID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$action = [
|
||||||
|
'type' => 'TEST_DELETE',
|
||||||
|
'testCode' => 'HBA1C',
|
||||||
|
];
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'order' => [
|
||||||
|
'InternalOID' => $internalOID,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->invokeMethod($this->engine, 'executeAction', [$action, $context]);
|
||||||
|
|
||||||
|
$deleted = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$this->assertNotNull($deleted);
|
||||||
|
$this->assertNotEmpty($deleted['DelDate'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to invoke protected/private methods
|
||||||
|
*/
|
||||||
|
private function invokeMethod($object, $methodName, array $parameters = [])
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass(get_class($object));
|
||||||
|
$method = $reflection->getMethod($methodName);
|
||||||
|
$method->setAccessible(true);
|
||||||
|
return $method->invokeArgs($object, $parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,25 +19,25 @@ class RuleExpressionCompileTest extends CIUnitTestCase
|
|||||||
{
|
{
|
||||||
$svc = new RuleExpressionService();
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
$compiled = $svc->compile("if(sex('F') ? set_result(0.7) : set_result(1))");
|
$compiled = $svc->compile("if(sex('F') ? result_set(0.7) : result_set(1))");
|
||||||
|
|
||||||
$this->assertIsArray($compiled);
|
$this->assertIsArray($compiled);
|
||||||
$this->assertEquals('order["Sex"] == "F"', $compiled['conditionExpr']);
|
$this->assertEquals('patient["Sex"] == "F"', $compiled['conditionExpr']);
|
||||||
$this->assertEquals(0.7, $compiled['then']['value']);
|
$this->assertEquals(0.7, $compiled['then'][0]['value']);
|
||||||
$this->assertEquals(1, $compiled['else']['value']);
|
$this->assertEquals(1, $compiled['else'][0]['value']);
|
||||||
$this->assertStringContainsString('order["Sex"] == "F"', $compiled['valueExpr']);
|
$this->assertStringContainsString('patient["Sex"] == "F"', $compiled['valueExpr']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCompilePriorityCondition(): void
|
public function testCompilePriorityCondition(): void
|
||||||
{
|
{
|
||||||
$svc = new RuleExpressionService();
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
$compiled = $svc->compile("if(priority('S') ? set_result('urgent') : set_result('normal'))");
|
$compiled = $svc->compile("if(priority('S') ? result_set('urgent') : result_set('normal'))");
|
||||||
|
|
||||||
$this->assertIsArray($compiled);
|
$this->assertIsArray($compiled);
|
||||||
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
|
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
|
||||||
$this->assertEquals('urgent', $compiled['then']['value']);
|
$this->assertEquals('urgent', $compiled['then'][0]['value']);
|
||||||
$this->assertEquals('normal', $compiled['else']['value']);
|
$this->assertEquals('normal', $compiled['else'][0]['value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCompileInvalidSyntax(): void
|
public function testCompileInvalidSyntax(): void
|
||||||
|
|||||||
346
tests/unit/Rules/RuleExpressionSyntaxTest.php
Normal file
346
tests/unit/Rules/RuleExpressionSyntaxTest.php
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Rules;
|
||||||
|
|
||||||
|
use App\Services\RuleExpressionService;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for Rule DSL syntax - semicolon syntax, multi-actions, operators
|
||||||
|
*/
|
||||||
|
class RuleExpressionSyntaxTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
protected RuleExpressionService $service;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->service = new RuleExpressionService();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SYNTAX TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testSemicolonSyntaxBasic()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('conditionExpr', $result);
|
||||||
|
$this->assertArrayHasKey('valueExpr', $result);
|
||||||
|
$this->assertArrayHasKey('then', $result);
|
||||||
|
$this->assertArrayHasKey('else', $result);
|
||||||
|
|
||||||
|
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
|
||||||
|
$this->assertEquals('0.5', $result['then'][0]['valueExpr']);
|
||||||
|
$this->assertEquals('0.6', $result['else'][0]['valueExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTernarySyntaxFallback()
|
||||||
|
{
|
||||||
|
// Legacy ternary syntax should still work (fallback)
|
||||||
|
$result = $this->service->compile('if(sex("M") ? result_set(0.5) : result_set(0.6))');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('conditionExpr', $result);
|
||||||
|
$this->assertEquals('patient["Sex"] == "M"', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MULTI-ACTION TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testMultiActionWithColon()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); result_set(0.5):test_insert("HBA1C"); nothing)');
|
||||||
|
|
||||||
|
$this->assertCount(2, $result['then']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals(0.5, $result['then'][0]['value']);
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
|
||||||
|
$this->assertEquals('HBA1C', $result['then'][1]['testCode']);
|
||||||
|
$this->assertEquals('NO_OP', $result['else'][0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThreeActions()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(priority("S"); result_set("URGENT"):test_insert("STAT_TEST"):comment_insert("Stat order"); nothing)');
|
||||||
|
|
||||||
|
$this->assertCount(3, $result['then']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('URGENT', $result['then'][0]['value']);
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
|
||||||
|
$this->assertEquals('STAT_TEST', $result['then'][1]['testCode']);
|
||||||
|
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
|
||||||
|
$this->assertEquals('Stat order', $result['then'][2]['comment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiActionInElse()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); result_set(1.0); result_set(0.8):comment_insert("Default"))');
|
||||||
|
|
||||||
|
$this->assertCount(1, $result['then']);
|
||||||
|
$this->assertCount(2, $result['else']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
|
||||||
|
$this->assertEquals(0.8, $result['else'][0]['value']);
|
||||||
|
$this->assertEquals('COMMENT_INSERT', $result['else'][1]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGICAL OPERATOR TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testAndOperator()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M") && age > 40; result_set(1.2); result_set(1.0))');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
|
||||||
|
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
|
||||||
|
$this->assertStringContainsString('age > 40', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrOperator()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M") || age > 65; result_set(1.0); result_set(0.8))');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
|
||||||
|
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
|
||||||
|
$this->assertStringContainsString('age > 65', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCombinedAndOr()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if((sex("M") && age > 40) || (sex("F") && age > 50); result_set(1.5); result_set(1.0))');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
|
||||||
|
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComplexNestedCondition()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M") && (age > 40 || priority("S")); result_set(1.2); nothing)');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('patient["Sex"] == "M"', $result['conditionExpr']);
|
||||||
|
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ACTION TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testNothingAction()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); result_set(0.5); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('NO_OP', $result['else'][0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNothingActionInThen()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); nothing; result_set(0.6))');
|
||||||
|
|
||||||
|
$this->assertEquals('NO_OP', $result['then'][0]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInsertAction()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddCommentAction()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("M"); comment_insert("Male patient"); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('COMMENT_INSERT', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('Male patient', $result['then'][0]['comment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONDITION TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testSexCondition()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(sex("F"); result_set(0.7); result_set(1.0))');
|
||||||
|
|
||||||
|
$this->assertEquals('patient["Sex"] == "F"', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPriorityCondition()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(priority("S"); result_set("URGENT"); result_set("NORMAL"))');
|
||||||
|
|
||||||
|
$this->assertEquals('order["Priority"] == "S"', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPriorityUrgent()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(priority("U"); result_set("CRITICAL"); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('order["Priority"] == "U"', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAgeCondition()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(age > 18; result_set(1.0); result_set(0.5))');
|
||||||
|
|
||||||
|
$this->assertEquals('age > 18', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAgeGreaterThanEqual()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(age >= 18; result_set(1.0); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('age >= 18', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAgeLessThan()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(age < 65; result_set(1.0); nothing)');
|
||||||
|
|
||||||
|
$this->assertEquals('age < 65', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAgeRange()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(age >= 18 && age <= 65; result_set(1.0); nothing)');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('age >= 18', $result['conditionExpr']);
|
||||||
|
$this->assertStringContainsString('age <= 65', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestedCondition()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('if(requested("GLU"); test_insert("HBA1C"); nothing)');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('requested', $result['conditionExpr']);
|
||||||
|
$this->assertStringContainsString('GLU', $result['conditionExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MULTI-RULE TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testParseMultiRule()
|
||||||
|
{
|
||||||
|
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing)';
|
||||||
|
$rules = $this->service->parseMultiRule($expr);
|
||||||
|
|
||||||
|
$this->assertCount(2, $rules);
|
||||||
|
$this->assertStringContainsString('sex("M")', $rules[0]);
|
||||||
|
$this->assertStringContainsString('age > 65', $rules[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseMultiRuleThreeRules()
|
||||||
|
{
|
||||||
|
$expr = 'if(sex("M"); result_set(0.5); nothing), if(age > 65; result_set(1.0); nothing), if(priority("S"); result_set(2.0); nothing)';
|
||||||
|
$rules = $this->service->parseMultiRule($expr);
|
||||||
|
|
||||||
|
$this->assertCount(3, $rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ERROR HANDLING TESTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testInvalidSyntaxThrowsException()
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->compile('invalid syntax here');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownActionThrowsException()
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->service->compile('if(sex("M"); unknown_action(); nothing)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyExpressionReturnsEmptyArray()
|
||||||
|
{
|
||||||
|
$result = $this->service->compile('');
|
||||||
|
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DOCUMENTATION EXAMPLES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function testExample1SexBasedResult()
|
||||||
|
{
|
||||||
|
// Example 1 from docs
|
||||||
|
$result = $this->service->compile('if(sex("M"); result_set(0.5); result_set(0.6))');
|
||||||
|
|
||||||
|
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals(0.5, $result['then'][0]['value']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['else'][0]['type']);
|
||||||
|
$this->assertEquals(0.6, $result['else'][0]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample2ConditionalTestInsertion()
|
||||||
|
{
|
||||||
|
// Example 2 from docs
|
||||||
|
$result = $this->service->compile("if(requested('GLU'); test_insert('HBA1C'):test_insert('INS'); nothing)");
|
||||||
|
|
||||||
|
$this->assertCount(2, $result['then']);
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('HBA1C', $result['then'][0]['testCode']);
|
||||||
|
$this->assertEquals('INS', $result['then'][1]['testCode']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample3MultipleConditionsAnd()
|
||||||
|
{
|
||||||
|
// Example 3 from docs
|
||||||
|
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.2); result_set(1.0))");
|
||||||
|
|
||||||
|
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample4OrCondition()
|
||||||
|
{
|
||||||
|
// Example 4 from docs
|
||||||
|
$result = $this->service->compile("if(sex('M') || age > 65; result_set(1.0); result_set(0.8))");
|
||||||
|
|
||||||
|
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample5CombinedAndOr()
|
||||||
|
{
|
||||||
|
// Example 5 from docs
|
||||||
|
$result = $this->service->compile("if((sex('M') && age > 40) || (sex('F') && age > 50); result_set(1.5); result_set(1.0))");
|
||||||
|
|
||||||
|
$this->assertStringContainsString('or', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample6MultipleActions()
|
||||||
|
{
|
||||||
|
// Example 6 from docs
|
||||||
|
$result = $this->service->compile("if(priority('S'); result_set('URGENT'):test_insert('STAT_TEST'); result_set('NORMAL'))");
|
||||||
|
|
||||||
|
$this->assertCount(2, $result['then']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample7ThreeActions()
|
||||||
|
{
|
||||||
|
// Example 7 from docs
|
||||||
|
$result = $this->service->compile("if(sex('M') && age > 40; result_set(1.5):test_insert('EXTRA_TEST'):comment_insert('Male over 40'); nothing)");
|
||||||
|
|
||||||
|
$this->assertCount(3, $result['then']);
|
||||||
|
$this->assertEquals('RESULT_SET', $result['then'][0]['type']);
|
||||||
|
$this->assertEquals('TEST_INSERT', $result['then'][1]['type']);
|
||||||
|
$this->assertEquals('COMMENT_INSERT', $result['then'][2]['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExample8ComplexRule()
|
||||||
|
{
|
||||||
|
// Example 8 from docs
|
||||||
|
$result = $this->service->compile("if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO'):comment_insert('Female stat 18-50'); result_set('NORMAL'))");
|
||||||
|
|
||||||
|
$this->assertCount(2, $result['then']);
|
||||||
|
$this->assertStringContainsString('and', strtolower($result['conditionExpr']));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user