feat: add calc endpoint and rule engine compilation

This commit is contained in:
mahdahar 2026-03-12 16:55:03 +07:00
parent 88be3f3809
commit c01786bb93
31 changed files with 2301 additions and 385 deletions

View File

@ -152,6 +152,8 @@ $routes->group('api', function ($routes) {
}); });
}); });
$routes->post('calc/(:any)', 'CalculatorController::calculateByCodeOrName/$1');
// Counter // Counter
$routes->group('counter', function ($routes) { $routes->group('counter', function ($routes) {
$routes->get('/', 'CounterController::index'); $routes->get('/', 'CounterController::index');
@ -354,6 +356,7 @@ $routes->group('api', function ($routes) {
$routes->patch('(:num)', 'Rule\RuleController::update/$1'); $routes->patch('(:num)', 'Rule\RuleController::update/$1');
$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->get('(:num)/actions', 'Rule\RuleActionController::index/$1'); $routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1'); $routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');

View File

@ -149,4 +149,35 @@ class CalculatorController extends Controller
], 400); ], 400);
} }
} }
/**
* POST api/calc/{codeOrName}
* Evaluate a configured calculation by its code or name and return only the result map.
*/
public function calculateByCodeOrName($codeOrName): ResponseInterface
{
try {
$calcDef = $this->calcModel->findActiveByCodeOrName($codeOrName);
if (!$calcDef || empty($calcDef['FormulaCode'])) {
return $this->response->setJSON(new \stdClass());
}
$input = $this->request->getJSON(true);
$variables = is_array($input) ? $input : [];
$result = $this->calculator->calculate($calcDef['FormulaCode'], $variables);
if ($result === null) {
return $this->response->setJSON(new \stdClass());
}
$responseKey = $calcDef['TestSiteCode'] ?? strtoupper($codeOrName);
return $this->response->setJSON([ $responseKey => $result ]);
} catch (\Exception $e) {
log_message('error', "Calc lookup failed for {$codeOrName}: " . $e->getMessage());
return $this->response->setJSON(new \stdClass());
}
}
} }

View File

@ -119,13 +119,39 @@ class OrderTestController extends Controller {
} }
private function getOrderTests($internalOID) { private function getOrderTests($internalOID) {
return $this->db->table('patres pr') $tests = $this->db->table('patres pr')
->select('pr.*, tds.TestSiteCode, tds.TestSiteName') ->select('pr.*, tds.TestSiteCode, tds.TestSiteName, tds.TestType, tds.SeqScr AS TestSeqScr, tds.SeqRpt AS TestSeqRpt, tds.DisciplineID, d.DisciplineCode, d.DisciplineName, d.SeqScr AS DisciplineSeqScr, d.SeqRpt AS DisciplineSeqRpt')
->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left') ->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left')
->join('discipline d', 'd.DisciplineID = tds.DisciplineID', 'left')
->where('pr.OrderID', $internalOID) ->where('pr.OrderID', $internalOID)
->where('pr.DelDate IS NULL') ->where('pr.DelDate IS NULL')
->orderBy('COALESCE(d.SeqScr, 999999) ASC')
->orderBy('COALESCE(d.SeqRpt, 999999) ASC')
->orderBy('COALESCE(tds.SeqScr, 999999) ASC')
->orderBy('COALESCE(tds.SeqRpt, 999999) ASC')
->orderBy('pr.ResultID ASC')
->get() ->get()
->getResultArray(); ->getResultArray();
foreach ($tests as &$test) {
$discipline = [
'DisciplineID' => $test['DisciplineID'] ?? null,
'DisciplineCode' => $test['DisciplineCode'] ?? null,
'DisciplineName' => $test['DisciplineName'] ?? null,
'SeqScr' => $test['DisciplineSeqScr'] ?? null,
'SeqRpt' => $test['DisciplineSeqRpt'] ?? null,
];
$test['Discipline'] = $discipline;
$test['SeqScr'] = $test['TestSeqScr'] ?? null;
$test['SeqRpt'] = $test['TestSeqRpt'] ?? null;
$test['DisciplineID'] = $discipline['DisciplineID'];
unset($test['DisciplineCode'], $test['DisciplineName'], $test['DisciplineSeqScr'], $test['DisciplineSeqRpt'], $test['TestSeqScr'], $test['TestSeqRpt']);
}
unset($test);
return $tests;
} }
public function create() { public function create() {

View File

@ -40,7 +40,6 @@ class RuleActionController extends BaseController
$rows = $this->ruleActionModel $rows = $this->ruleActionModel
->where('RuleID', (int) $ruleID) ->where('RuleID', (int) $ruleID)
->where('EndDate', null) ->where('EndDate', null)
->orderBy('Seq', 'ASC')
->orderBy('RuleActionID', 'ASC') ->orderBy('RuleActionID', 'ASC')
->findAll(); ->findAll();
@ -77,7 +76,6 @@ class RuleActionController extends BaseController
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'Seq' => 'permit_empty|integer',
'ActionType' => 'required|max_length[50]', 'ActionType' => 'required|max_length[50]',
]); ]);
@ -117,7 +115,6 @@ class RuleActionController extends BaseController
$id = $this->ruleActionModel->insert([ $id = $this->ruleActionModel->insert([
'RuleID' => (int) $ruleID, 'RuleID' => (int) $ruleID,
'Seq' => $input['Seq'] ?? 1,
'ActionType' => $input['ActionType'], 'ActionType' => $input['ActionType'],
'ActionParams' => is_string($params) ? $params : null, 'ActionParams' => is_string($params) ? $params : null,
], true); ], true);
@ -167,7 +164,6 @@ class RuleActionController extends BaseController
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'Seq' => 'permit_empty|integer',
'ActionType' => 'permit_empty|max_length[50]', 'ActionType' => 'permit_empty|max_length[50]',
]); ]);
if (!$validation->run($input)) { if (!$validation->run($input)) {
@ -176,7 +172,7 @@ class RuleActionController extends BaseController
try { try {
$updateData = []; $updateData = [];
foreach (['Seq', 'ActionType', 'ActionParams'] as $field) { foreach (['ActionType', 'ActionParams'] as $field) {
if (array_key_exists($field, $input)) { if (array_key_exists($field, $input)) {
$updateData[$field] = $input[$field]; $updateData[$field] = $input[$field];
} }

View File

@ -26,32 +26,27 @@ class RuleController extends BaseController
{ {
try { try {
$eventCode = $this->request->getGet('EventCode'); $eventCode = $this->request->getGet('EventCode');
$active = $this->request->getGet('Active');
$scopeType = $this->request->getGet('ScopeType');
$testSiteID = $this->request->getGet('TestSiteID'); $testSiteID = $this->request->getGet('TestSiteID');
$search = $this->request->getGet('search'); $search = $this->request->getGet('search');
$builder = $this->ruleDefModel->where('EndDate', null); $builder = $this->ruleDefModel->where('ruledef.EndDate', null);
if ($eventCode !== null && $eventCode !== '') { if ($eventCode !== null && $eventCode !== '') {
$builder->where('EventCode', $eventCode); $builder->where('ruledef.EventCode', $eventCode);
}
if ($active !== null && $active !== '') {
$builder->where('Active', (int) $active);
}
if ($scopeType !== null && $scopeType !== '') {
$builder->where('ScopeType', $scopeType);
}
if ($testSiteID !== null && $testSiteID !== '' && is_numeric($testSiteID)) {
$builder->where('TestSiteID', (int) $testSiteID);
} }
if ($search !== null && $search !== '') { if ($search !== null && $search !== '') {
$builder->like('Name', $search); $builder->like('ruledef.RuleName', $search);
}
// Filter by TestSiteID - join with mapping table
if ($testSiteID !== null && $testSiteID !== '' && is_numeric($testSiteID)) {
$builder->join('testrule', 'testrule.RuleID = ruledef.RuleID', 'inner');
$builder->where('testrule.TestSiteID', (int) $testSiteID);
$builder->where('testrule.EndDate IS NULL');
} }
$rows = $builder $rows = $builder
->orderBy('Priority', 'ASC') ->orderBy('ruledef.RuleID', 'ASC')
->orderBy('RuleID', 'ASC')
->findAll(); ->findAll();
return $this->respond([ return $this->respond([
@ -88,11 +83,13 @@ class RuleController extends BaseController
$actions = $this->ruleActionModel $actions = $this->ruleActionModel
->where('RuleID', (int) $id) ->where('RuleID', (int) $id)
->where('EndDate', null) ->where('EndDate', null)
->orderBy('Seq', 'ASC')
->orderBy('RuleActionID', 'ASC') ->orderBy('RuleActionID', 'ASC')
->findAll(); ->findAll();
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
$rule['actions'] = $actions; $rule['actions'] = $actions;
$rule['linkedTests'] = $linkedTests;
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
@ -115,30 +112,30 @@ class RuleController extends BaseController
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'Name' => 'required|max_length[100]', 'RuleCode' => 'required|max_length[50]',
'RuleName' => 'required|max_length[100]',
'EventCode' => 'required|max_length[50]', 'EventCode' => 'required|max_length[50]',
'ScopeType' => 'required|in_list[GLOBAL,TESTSITE]', 'TestSiteIDs' => 'required',
'TestSiteID' => 'permit_empty|is_natural_no_zero', 'TestSiteIDs.*' => 'is_natural_no_zero',
'ConditionExpr' => 'permit_empty|max_length[1000]', 'ConditionExpr' => 'permit_empty|max_length[1000]',
'Priority' => 'permit_empty|integer',
'Active' => 'permit_empty|in_list[0,1]',
]); ]);
if (!$validation->run($input)) { if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors()); return $this->failValidationErrors($validation->getErrors());
} }
if (($input['ScopeType'] ?? 'GLOBAL') === 'TESTSITE') { $testSiteIDs = $input['TestSiteIDs'] ?? [];
if (empty($input['TestSiteID'])) { if (!is_array($testSiteIDs) || empty($testSiteIDs)) {
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']); return $this->failValidationErrors(['TestSiteIDs' => 'At least one TestSiteID is required']);
} }
$testDef = new TestDefSiteModel();
$exists = $testDef->where('EndDate', null)->find((int) $input['TestSiteID']); // Validate all TestSiteIDs exist
$testDef = new TestDefSiteModel();
foreach ($testSiteIDs as $testSiteID) {
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
if (!$exists) { if (!$exists) {
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']); return $this->failValidationErrors(['TestSiteIDs' => "TestSiteID {$testSiteID} not found"]);
} }
} else {
$input['TestSiteID'] = null;
} }
$db = \Config\Database::connect(); $db = \Config\Database::connect();
@ -146,14 +143,12 @@ class RuleController extends BaseController
try { try {
$ruleData = [ $ruleData = [
'Name' => $input['Name'], 'RuleCode' => $input['RuleCode'],
'RuleName' => $input['RuleName'],
'Description' => $input['Description'] ?? null, 'Description' => $input['Description'] ?? null,
'EventCode' => $input['EventCode'], 'EventCode' => $input['EventCode'],
'ScopeType' => $input['ScopeType'],
'TestSiteID' => $input['TestSiteID'] ?? null,
'ConditionExpr' => $input['ConditionExpr'] ?? null, 'ConditionExpr' => $input['ConditionExpr'] ?? null,
'Priority' => $input['Priority'] ?? 100, 'ConditionExprCompiled' => $input['ConditionExprCompiled'] ?? null,
'Active' => isset($input['Active']) ? (int) $input['Active'] : 1,
]; ];
$ruleID = $this->ruleDefModel->insert($ruleData, true); $ruleID = $this->ruleDefModel->insert($ruleData, true);
@ -161,6 +156,12 @@ class RuleController extends BaseController
throw new \Exception('Failed to create rule'); throw new \Exception('Failed to create rule');
} }
// Link rule to test sites
foreach ($testSiteIDs as $testSiteID) {
$this->ruleDefModel->linkTest($ruleID, (int) $testSiteID);
}
// Create actions if provided
if (isset($input['actions']) && is_array($input['actions'])) { if (isset($input['actions']) && is_array($input['actions'])) {
foreach ($input['actions'] as $action) { foreach ($input['actions'] as $action) {
if (!is_array($action)) { if (!is_array($action)) {
@ -179,7 +180,6 @@ class RuleController extends BaseController
$this->ruleActionModel->insert([ $this->ruleActionModel->insert([
'RuleID' => $ruleID, 'RuleID' => $ruleID,
'Seq' => $action['Seq'] ?? 1,
'ActionType' => $actionType, 'ActionType' => $actionType,
'ActionParams' => is_string($params) ? $params : null, 'ActionParams' => is_string($params) ? $params : null,
]); ]);
@ -225,54 +225,76 @@ class RuleController extends BaseController
$validation = service('validation'); $validation = service('validation');
$validation->setRules([ $validation->setRules([
'Name' => 'permit_empty|max_length[100]', 'RuleCode' => 'permit_empty|max_length[50]',
'RuleName' => 'permit_empty|max_length[100]',
'EventCode' => 'permit_empty|max_length[50]', 'EventCode' => 'permit_empty|max_length[50]',
'ScopeType' => 'permit_empty|in_list[GLOBAL,TESTSITE]', 'TestSiteIDs' => 'permit_empty',
'TestSiteID' => 'permit_empty|is_natural_no_zero', 'TestSiteIDs.*' => 'is_natural_no_zero',
'ConditionExpr' => 'permit_empty|max_length[1000]', 'ConditionExpr' => 'permit_empty|max_length[1000]',
'Priority' => 'permit_empty|integer',
'Active' => 'permit_empty|in_list[0,1]',
]); ]);
if (!$validation->run($input)) { if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors()); return $this->failValidationErrors($validation->getErrors());
} }
$scopeType = $input['ScopeType'] ?? $existing['ScopeType'] ?? 'GLOBAL'; $db = \Config\Database::connect();
$testSiteID = array_key_exists('TestSiteID', $input) ? $input['TestSiteID'] : ($existing['TestSiteID'] ?? null); $db->transStart();
if ($scopeType === 'TESTSITE') {
if (empty($testSiteID)) {
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']);
}
$testDef = new TestDefSiteModel();
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
if (!$exists) {
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']);
}
} else {
$testSiteID = null;
}
try { try {
$updateData = []; $updateData = [];
foreach (['Name', 'Description', 'EventCode', 'ScopeType', 'ConditionExpr', 'Priority', 'Active'] as $field) { foreach (['RuleCode', 'RuleName', 'Description', 'EventCode', 'ConditionExpr', 'ConditionExprCompiled'] as $field) {
if (array_key_exists($field, $input)) { if (array_key_exists($field, $input)) {
$updateData[$field] = $input[$field]; $updateData[$field] = $input[$field];
} }
} }
$updateData['TestSiteID'] = $testSiteID;
if (!empty($updateData)) { if (!empty($updateData)) {
$this->ruleDefModel->update((int) $id, $updateData); $this->ruleDefModel->update((int) $id, $updateData);
} }
// Update test site mappings if provided
if (isset($input['TestSiteIDs']) && is_array($input['TestSiteIDs'])) {
$testSiteIDs = $input['TestSiteIDs'];
// Validate all TestSiteIDs exist
$testDef = new TestDefSiteModel();
foreach ($testSiteIDs as $testSiteID) {
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
if (!$exists) {
throw new \Exception("TestSiteID {$testSiteID} not found");
}
}
// Get current linked tests
$currentLinks = $this->ruleDefModel->getLinkedTests((int) $id);
// Unlink tests that are no longer in the list
foreach ($currentLinks as $currentTestSiteID) {
if (!in_array($currentTestSiteID, $testSiteIDs)) {
$this->ruleDefModel->unlinkTest((int) $id, $currentTestSiteID);
}
}
// Link new tests
foreach ($testSiteIDs as $testSiteID) {
if (!in_array($testSiteID, $currentLinks)) {
$this->ruleDefModel->linkTest((int) $id, (int) $testSiteID);
}
}
}
$db->transComplete();
if ($db->transStatus() === false) {
throw new \Exception('Transaction failed');
}
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'Rule updated successfully', 'message' => 'Rule updated successfully',
'data' => ['RuleID' => (int) $id], 'data' => ['RuleID' => (int) $id],
], 200); ], 200);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'RuleController::update error: ' . $e->getMessage()); log_message('error', 'RuleController::update error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
@ -341,4 +363,40 @@ class RuleController extends BaseController
], 200); ], 200);
} }
} }
/**
* Compile DSL expression to engine-compatible structure.
* Frontend calls this when user clicks "Compile" button.
*/
public function compile()
{
$input = $this->request->getJSON(true) ?? [];
$expr = $input['expr'] ?? '';
if (!is_string($expr) || trim($expr) === '') {
return $this->failValidationErrors(['expr' => 'Expression is required']);
}
try {
$svc = new RuleExpressionService();
$compiled = $svc->compile($expr);
return $this->respond([
'status' => 'success',
'data' => [
'raw' => $expr,
'compiled' => $compiled,
'conditionExprCompiled' => json_encode($compiled),
],
], 200);
} catch (\Throwable $e) {
return $this->respond([
'status' => 'failed',
'message' => 'Compilation failed',
'data' => [
'error' => $e->getMessage(),
],
], 400);
}
}
} }

View File

@ -487,6 +487,13 @@ class TestsController extends BaseController
} }
$memberIDs = $this->resolveCalcMemberIDs($data, $input); $memberIDs = $this->resolveCalcMemberIDs($data, $input);
// Validate member IDs before insertion
$validation = $this->validateMemberIDs($memberIDs);
if (!$validation['valid']) {
throw new \Exception('Invalid member TestSiteID(s): ' . implode(', ', $validation['invalid']) . '. Make sure to use TestSiteID, not SeqScr or other values.');
}
foreach ($memberIDs as $memberID) { foreach ($memberIDs as $memberID) {
$this->modelGrp->insert([ $this->modelGrp->insert([
'TestSiteID' => $testSiteID, 'TestSiteID' => $testSiteID,
@ -503,7 +510,8 @@ class TestsController extends BaseController
if (is_array($rawMembers)) { if (is_array($rawMembers)) {
foreach ($rawMembers as $member) { foreach ($rawMembers as $member) {
if (is_array($member)) { if (is_array($member)) {
$rawID = $member['Member'] ?? ($member['TestSiteID'] ?? null); // Only accept TestSiteID, not Member (which might be SeqScr)
$rawID = $member['TestSiteID'] ?? null;
} else { } else {
$rawID = is_numeric($member) ? $member : null; $rawID = is_numeric($member) ? $member : null;
} }
@ -519,6 +527,31 @@ class TestsController extends BaseController
return $memberIDs; return $memberIDs;
} }
/**
* Validate that member IDs exist in testdefsite table
*
* @param array $memberIDs Array of TestSiteID values to validate
* @return array ['valid' => bool, 'invalid' => array]
*/
private function validateMemberIDs(array $memberIDs): array
{
if (empty($memberIDs)) {
return ['valid' => true, 'invalid' => []];
}
$existing = $this->model->whereIn('TestSiteID', $memberIDs)
->where('EndDate IS NULL')
->findAll();
$existingIDs = array_column($existing, 'TestSiteID');
$invalidIDs = array_diff($memberIDs, $existingIDs);
return [
'valid' => empty($invalidIDs),
'invalid' => array_values($invalidIDs)
];
}
private function saveGroupDetails($testSiteID, $data, $input, $action) private function saveGroupDetails($testSiteID, $data, $input, $action)
{ {
if ($action === 'update') { if ($action === 'update') {
@ -526,18 +559,32 @@ class TestsController extends BaseController
} }
$members = $data['members'] ?? ($input['Members'] ?? []); $members = $data['members'] ?? ($input['Members'] ?? []);
$memberIDs = [];
if (is_array($members)) { if (is_array($members)) {
foreach ($members as $m) { foreach ($members as $m) {
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m; // Only accept TestSiteID, not Member (which might be SeqScr)
if ($memberID) { $memberID = is_array($m) ? ($m['TestSiteID'] ?? null) : $m;
$this->modelGrp->insert([ if ($memberID && is_numeric($memberID)) {
'TestSiteID' => $testSiteID, $memberIDs[] = (int) $memberID;
'Member' => $memberID,
]);
} }
} }
} }
$memberIDs = array_values(array_unique(array_filter($memberIDs)));
// Validate member IDs before insertion
$validation = $this->validateMemberIDs($memberIDs);
if (!$validation['valid']) {
throw new \Exception('Invalid member TestSiteID(s): ' . implode(', ', $validation['invalid']) . '. Make sure to use TestSiteID, not SeqScr or other values.');
}
foreach ($memberIDs as $memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID,
]);
}
} }
private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action) private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action)

View File

@ -4,39 +4,53 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration; use CodeIgniter\Database\Migration;
class CreateRules extends Migration /**
* Replace ruledef/ruleaction with testrule schema
* Rules can now be linked to multiple tests via testrule_testsite mapping table
*/
class CreateTestRules extends Migration
{ {
public function up() public function up()
{ {
// ruledef // ruledef - rule definitions (not linked to specific test)
$this->forge->addField([ $this->forge->addField([
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'RuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'Name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false], 'RuleCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'RuleName' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
'Description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], 'Description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'EventCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], 'EventCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'ScopeType' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'GLOBAL'],
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
'ConditionExpr' => ['type' => 'VARCHAR', 'constraint' => 1000, 'null' => true], 'ConditionExpr' => ['type' => 'VARCHAR', 'constraint' => 1000, 'null' => true],
'Priority' => ['type' => 'INT', 'null' => true, 'default' => 100], 'ConditionExprCompiled' => ['type' => 'JSON', 'null' => true],
'Active' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true, 'default' => 1],
'CreateDate' => ['type' => 'DATETIME', 'null' => true], 'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'StartDate' => ['type' => 'DATETIME', 'null' => true], 'StartDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true], 'EndDate' => ['type' => 'DATETIME', 'null' => true],
]); ]);
$this->forge->addKey('RuleID', true); $this->forge->addKey('RuleID', true);
$this->forge->addKey('RuleCode');
$this->forge->addKey('EventCode'); $this->forge->addKey('EventCode');
$this->forge->addKey('ScopeType');
$this->forge->addKey('TestSiteID');
$this->forge->createTable('ruledef'); $this->forge->createTable('ruledef');
// Optional scope FK (only when ScopeType=TESTSITE) // testrule - mapping table for many-to-many relationship between ruledef and tests
$this->db->query('ALTER TABLE `ruledef` ADD CONSTRAINT `fk_ruledef_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE'); $this->forge->addField([
'TestRuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
'EndDate' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('TestRuleID', true);
$this->forge->addKey('RuleID');
$this->forge->addKey('TestSiteID');
$this->forge->createTable('testrule');
// ruleaction // 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_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
// ruleaction - actions for rules
$this->forge->addField([ $this->forge->addField([
'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], 'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], 'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'Seq' => ['type' => 'INT', 'null' => true, 'default' => 1],
'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], 'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'ActionParams' => ['type' => 'TEXT', 'null' => true], 'ActionParams' => ['type' => 'TEXT', 'null' => true],
'CreateDate' => ['type' => 'DATETIME', 'null' => true], 'CreateDate' => ['type' => 'DATETIME', 'null' => true],
@ -51,7 +65,8 @@ class CreateRules extends Migration
public function down() public function down()
{ {
$this->forge->dropTable('ruleaction'); $this->forge->dropTable('ruleaction', true);
$this->forge->dropTable('ruledef'); $this->forge->dropTable('testrule', true);
$this->forge->dropTable('ruledef', true);
} }
} }

View File

@ -449,6 +449,55 @@ class OrderSeeder extends Seeder
// Create order status // Create order status
$this->createOrderStatus($internalOID5, 'ORDERED', $now); $this->createOrderStatus($internalOID5, 'ORDERED', $now);
// ========================================
// ORDER 6: Patient 2 - Bilirubin Panel (TBIL, DBIL, IBIL)
// ========================================
$orderID6 = '001' . date('ymd') . '00006';
$internalOID6 = $this->createOrder([
'OrderID' => $orderID6,
'PlacerID' => 'PLC006',
'InternalPID' => 2,
'SiteID' => '1',
'PVADTID' => 3,
'ReqApp' => 'HIS',
'Priority' => 'R',
'TrnDate' => $now,
'EffDate' => $now,
'CreateDate' => $now
]);
echo "\nCreated Order 6: {$orderID6} (InternalOID: {$internalOID6}) [Bilirubin Panel]\n";
// Create specimen for Bilirubin tests (SST tube - ConDefID = 1)
$specimenID6 = $orderID6 . '-S01';
$internalSID6 = $this->createSpecimen([
'SID' => $specimenID6,
'SiteID' => '1',
'OrderID' => $internalOID6,
'ConDefID' => 1,
'Qty' => 1,
'Unit' => 'tube',
'GenerateBy' => 'ORDER',
'CreateDate' => $now
]);
$this->createSpecimenStatus($specimenID6, $internalOID6, 'PENDING', $now);
$this->createSpecimenStatus($specimenID6, $internalOID6, 'COLLECTED', date('Y-m-d H:i:s', strtotime('+30 minutes')));
$this->createSpecimenStatus($specimenID6, $internalOID6, 'RECEIVED', date('Y-m-d H:i:s', strtotime('+2 hours')));
$this->createSpecimenStatus($specimenID6, $internalOID6, 'COMPLETED', date('Y-m-d H:i:s', strtotime('+6 hours')));
echo " Created Specimen: {$specimenID6} (SST)\n";
// Create order status
$this->createOrderStatus($internalOID6, 'ORDERED', $now);
$this->createOrderStatus($internalOID6, 'SPECIMEN_COLLECTED', date('Y-m-d H:i:s', strtotime('+30 minutes')));
$this->createOrderStatus($internalOID6, 'IN_LAB', date('Y-m-d H:i:s', strtotime('+2 hours')));
$this->createOrderStatus($internalOID6, 'COMPLETED', date('Y-m-d H:i:s', strtotime('+6 hours')));
$this->createOrderStatus($internalOID6, 'REPORTED', date('Y-m-d H:i:s', strtotime('+7 hours')));
// Create order comment
$this->createOrderComment($internalOID6, 'Bilirubin panel ordered for liver function assessment', $now);
// ======================================== // ========================================
// SUMMARY // SUMMARY
// ======================================== // ========================================
@ -456,15 +505,16 @@ class OrderSeeder extends Seeder
echo "========================================\n"; echo "========================================\n";
echo "ORDER SEEDING COMPLETED SUCCESSFULLY!\n"; echo "ORDER SEEDING COMPLETED SUCCESSFULLY!\n";
echo "========================================\n"; echo "========================================\n";
echo "Orders Created: 5\n"; echo "Orders Created: 6\n";
echo " - Order 1: CBC (Complete Blood Count) - COMPLETED\n"; echo " - Order 1: CBC (Complete Blood Count) - COMPLETED\n";
echo " - Order 2: Lipid + Liver Profile - COMPLETED\n"; echo " - Order 2: Lipid + Liver Profile - COMPLETED\n";
echo " - Order 3: Renal + Glucose - COMPLETED (URGENT)\n"; echo " - Order 3: Renal + Glucose - COMPLETED (URGENT)\n";
echo " - Order 4: Urinalysis - PENDING\n"; echo " - Order 4: Urinalysis - PENDING\n";
echo " - Order 5: CBC + Chemistry - PENDING (Multi-container)\n"; echo " - Order 5: CBC + Chemistry - PENDING (Multi-container)\n";
echo " - Order 6: Bilirubin Panel (TBIL, DBIL, IBIL) - COMPLETED\n";
echo "----------------------------------------\n"; echo "----------------------------------------\n";
echo "Specimens Created: 6\n"; echo "Specimens Created: 7\n";
echo " - SST tubes: 4\n"; echo " - SST tubes: 5\n";
echo " - EDTA tubes: 2\n"; echo " - EDTA tubes: 2\n";
echo " - Urine containers: 1\n"; echo " - Urine containers: 1\n";
echo "----------------------------------------\n"; echo "----------------------------------------\n";

View File

@ -2,7 +2,7 @@
"VSName": "Enable/Disable", "VSName": "Enable/Disable",
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "0", "value": "Disabled"}, {"key": "D", "value": "Disabled"},
{"key": "1", "value": "Enabled"} {"key": "E", "value": "Enabled"}
] ]
} }

View File

@ -2,8 +2,8 @@
"VSName": "Sex", "VSName": "Sex",
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "1", "value": "Female"}, {"key": "F", "value": "Female"},
{"key": "2", "value": "Male"}, {"key": "M", "value": "Male"},
{"key": "3", "value": "Unknown"} {"key": "U", "value": "Unknown"}
] ]
} }

View File

@ -2,7 +2,7 @@
"VSName": "Workstation Type", "VSName": "Workstation Type",
"VCategory": "System", "VCategory": "System",
"values": [ "values": [
{"key": "0", "value": "Primary"}, {"key": "PRI", "value": "Primary"},
{"key": "1", "value": "Secondary"} {"key": "SEC", "value": "Secondary"}
] ]
} }

View File

@ -4,13 +4,17 @@ namespace App\Models\Rule;
use App\Models\BaseModel; use App\Models\BaseModel;
/**
* RuleAction Model
*
* Actions that can be executed when a rule matches.
*/
class RuleActionModel extends BaseModel class RuleActionModel extends BaseModel
{ {
protected $table = 'ruleaction'; protected $table = 'ruleaction';
protected $primaryKey = 'RuleActionID'; protected $primaryKey = 'RuleActionID';
protected $allowedFields = [ protected $allowedFields = [
'RuleID', 'RuleID',
'Seq',
'ActionType', 'ActionType',
'ActionParams', 'ActionParams',
'CreateDate', 'CreateDate',
@ -24,6 +28,12 @@ class RuleActionModel extends BaseModel
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
protected $deletedField = 'EndDate'; 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 public function getActiveByRuleIDs(array $ruleIDs): array
{ {
if (empty($ruleIDs)) { if (empty($ruleIDs)) {
@ -33,7 +43,6 @@ class RuleActionModel extends BaseModel
return $this->whereIn('RuleID', $ruleIDs) return $this->whereIn('RuleID', $ruleIDs)
->where('EndDate IS NULL') ->where('EndDate IS NULL')
->orderBy('RuleID', 'ASC') ->orderBy('RuleID', 'ASC')
->orderBy('Seq', 'ASC')
->orderBy('RuleActionID', 'ASC') ->orderBy('RuleActionID', 'ASC')
->findAll(); ->findAll();
} }

View File

@ -4,19 +4,22 @@ namespace App\Models\Rule;
use App\Models\BaseModel; use App\Models\BaseModel;
/**
* RuleDef Model
*
* Rule definitions that can be linked to multiple tests via testrule mapping table.
*/
class RuleDefModel extends BaseModel class RuleDefModel extends BaseModel
{ {
protected $table = 'ruledef'; protected $table = 'ruledef';
protected $primaryKey = 'RuleID'; protected $primaryKey = 'RuleID';
protected $allowedFields = [ protected $allowedFields = [
'Name', 'RuleCode',
'RuleName',
'Description', 'Description',
'EventCode', 'EventCode',
'ScopeType',
'TestSiteID',
'ConditionExpr', 'ConditionExpr',
'Priority', 'ConditionExprCompiled',
'Active',
'CreateDate', 'CreateDate',
'StartDate', 'StartDate',
'EndDate', 'EndDate',
@ -30,32 +33,108 @@ class RuleDefModel extends BaseModel
protected $deletedField = 'EndDate'; protected $deletedField = 'EndDate';
/** /**
* Fetch active rules for an event, optionally scoped. * Fetch active rules for an event scoped by TestSiteID.
* *
* Scope behavior: * Rules are standalone and only apply when explicitly linked to a test
* - Always returns GLOBAL rules * via the testrule mapping table.
* - If $testSiteID provided, also returns TESTSITE rules matching TestSiteID *
* @param string $eventCode The event code to filter by
* @param int|null $testSiteID The test site ID to filter by
* @return array Array of matching rules
*/ */
public function getActiveByEvent(string $eventCode, ?int $testSiteID = null): array public function getActiveByEvent(string $eventCode, ?int $testSiteID = null): array
{ {
$builder = $this->where('EventCode', $eventCode) if ($testSiteID === null) {
->where('EndDate IS NULL') return [];
->where('Active', 1)
->groupStart()
->where('ScopeType', 'GLOBAL');
if ($testSiteID !== null) {
$builder->orGroupStart()
->where('ScopeType', 'TESTSITE')
->where('TestSiteID', $testSiteID)
->groupEnd();
} }
$builder->groupEnd(); return $this->select('ruledef.*')
->join('testrule', 'testrule.RuleID = ruledef.RuleID', 'inner')
return $builder ->where('ruledef.EventCode', $eventCode)
->orderBy('Priority', 'ASC') ->where('ruledef.EndDate IS NULL')
->orderBy('RuleID', 'ASC') ->where('testrule.TestSiteID', $testSiteID)
->where('testrule.EndDate IS NULL')
->orderBy('ruledef.RuleID', 'ASC')
->findAll(); ->findAll();
} }
/**
* Get all tests linked to a rule
*
* @param int $ruleID The rule ID
* @return array Array of test site IDs
*/
public function getLinkedTests(int $ruleID): array
{
$db = \Config\Database::connect();
$result = $db->table('testrule')
->where('RuleID', $ruleID)
->where('EndDate IS NULL')
->select('TestSiteID')
->get()
->getResultArray();
return array_column($result, 'TestSiteID');
}
/**
* Link a rule to a test
*
* @param int $ruleID The rule ID
* @param int $testSiteID The test site ID
* @return bool Success status
*/
public function linkTest(int $ruleID, int $testSiteID): bool
{
$db = \Config\Database::connect();
// Check if already linked (and not soft deleted)
$existing = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->first();
if ($existing) {
return true; // Already linked
}
// Check if soft deleted - restore it
$softDeleted = $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NOT NULL')
->first();
if ($softDeleted) {
return $db->table('testrule')
->where('TestRuleID', $softDeleted['TestRuleID'])
->update(['EndDate' => null]);
}
// Create new link
return $db->table('testrule')->insert([
'RuleID' => $ruleID,
'TestSiteID' => $testSiteID,
'CreateDate' => date('Y-m-d H:i:s'),
]);
}
/**
* Unlink a rule from a test (soft delete)
*
* @param int $ruleID The rule ID
* @param int $testSiteID The test site ID
* @return bool Success status
*/
public function unlinkTest(int $ruleID, int $testSiteID): bool
{
$db = \Config\Database::connect();
return $db->table('testrule')
->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->update(['EndDate' => date('Y-m-d H:i:s')]);
}
} }

View File

@ -0,0 +1,71 @@
<?php
namespace App\Models\Rule;
use App\Models\BaseModel;
/**
* TestRule Model
*
* Mapping table linking ruledef to test sites.
* Represents which tests a rule is linked to.
*/
class TestRuleModel extends BaseModel
{
protected $table = 'testrule';
protected $primaryKey = 'TestRuleID';
protected $allowedFields = [
'RuleID',
'TestSiteID',
'CreateDate',
'EndDate',
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = 'EndDate';
/**
* Get all active test site mappings for a rule
*
* @param int $ruleID The rule ID
* @return array Array of mappings with test site details
*/
public function getByRuleID(int $ruleID): array
{
return $this->where('RuleID', $ruleID)
->where('EndDate IS NULL')
->findAll();
}
/**
* Get all active rules mapped to a test site
*
* @param int $testSiteID The test site ID
* @return array Array of mappings
*/
public function getByTestSiteID(int $testSiteID): array
{
return $this->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->findAll();
}
/**
* Check if a rule is linked to a test site
*
* @param int $ruleID The rule ID
* @param int $testSiteID The test site ID
* @return bool True if linked and active
*/
public function isLinked(int $ruleID, int $testSiteID): bool
{
return $this->where('RuleID', $ruleID)
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->countAllResults() > 0;
}
}

View File

@ -51,6 +51,34 @@ class TestDefCalModel extends BaseModel {
->get()->getRowArray(); ->get()->getRowArray();
} }
/**
* Find an active calculation by TestSiteCode or TestSiteName (case-insensitive).
*/
public function findActiveByCodeOrName(string $codeOrName): ?array
{
$identifier = mb_strtolower(trim($codeOrName));
if ($identifier === '') {
return null;
}
$builder = $this->db->table('testdefcal cal')
->select('cal.*, site.TestSiteCode, site.TestSiteName')
->join('testdefsite site', 'site.TestSiteID=cal.TestSiteID', 'left')
->where('cal.EndDate IS NULL')
->where('site.EndDate IS NULL')
->groupStart()
->where('LOWER(site.TestSiteCode)', $identifier)
->orWhere('LOWER(site.TestSiteName)', $identifier)
->groupEnd()
->orderBy('cal.CreateDate', 'DESC')
->limit(1);
$row = $builder->get()->getRowArray();
return $row ?: null;
}
/** /**
* Disable calculation by TestSiteID * Disable calculation by TestSiteID
*/ */

View File

@ -38,8 +38,8 @@ class CalculatorService {
*/ */
public function calculate(string $formula, array $variables = []): ?float { public function calculate(string $formula, array $variables = []): ?float {
try { try {
// Convert placeholders to math-parser compatible format $normalizedFormula = $this->normalizeFormulaVariables($formula, $variables);
$expression = $this->prepareExpression($formula, $variables); $expression = $this->prepareExpression($normalizedFormula, $variables);
// Parse the expression // Parse the expression
$ast = $this->parser->parse($expression); $ast = $this->parser->parse($expression);
@ -115,6 +115,32 @@ class CalculatorService {
return $expression; return $expression;
} }
/**
* Normalize formulas that reference raw variable names instead of placeholders.
*/
protected function normalizeFormulaVariables(string $formula, array $variables): string
{
if (str_contains($formula, '{')) {
return $formula;
}
if (empty($variables)) {
return $formula;
}
$keys = array_keys($variables);
usort($keys, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a));
foreach ($keys as $key) {
$escaped = preg_quote($key, '/');
$formula = preg_replace_callback('/\b' . $escaped . '\b/i', function () use ($key) {
return '{' . $key . '}';
}, $formula);
}
return $formula;
}
/** /**
* Normalize gender value to numeric (0, 1, or 2) * Normalize gender value to numeric (0, 1, or 2)
*/ */

View File

@ -59,13 +59,34 @@ class RuleEngineService
} }
try { try {
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context); // Check for compiled expression first
if (!$matches) { $compiled = null;
continue; if (!empty($rule['ConditionExprCompiled'])) {
$compiled = json_decode($rule['ConditionExprCompiled'], true);
} }
foreach ($actionsByRule[$rid] ?? [] as $action) { if (!empty($compiled) && is_array($compiled)) {
$this->executeAction($action, $context); // Compiled rule: evaluate condition from compiled structure
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
$matches = $this->expr->evaluateBoolean($conditionExpr, $context);
if (!$matches) {
continue;
}
// Use compiled valueExpr for SET_RESULT action
if (!empty($compiled['valueExpr'])) {
$this->executeCompiledSetResult($rid, $compiled['valueExpr'], $context);
}
} else {
// Legacy rule: evaluate raw ConditionExpr and execute stored actions
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context);
if (!$matches) {
continue;
}
foreach ($actionsByRule[$rid] ?? [] as $action) {
$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());
@ -74,6 +95,71 @@ class RuleEngineService
} }
} }
/**
* Execute SET_RESULT action using compiled valueExpr.
* Automatically creates the test result if it doesn't exist.
*/
protected function executeCompiledSetResult(int $ruleID, string $valueExpr, array $context): void
{
$order = $context['order'] ?? null;
if (!is_array($order) || empty($order['InternalOID'])) {
throw new \Exception('SET_RESULT requires context.order.InternalOID');
}
$internalOID = (int) $order['InternalOID'];
$testSiteID = $context['testSiteID'] ?? null;
if ($testSiteID === null && isset($order['TestSiteID'])) {
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
}
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) {
throw new \Exception('SET_RESULT requires testSiteID');
}
// Evaluate the value expression
$value = $this->expr->evaluate($valueExpr, $context);
$db = \Config\Database::connect();
// Check if patres row exists
$patres = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->get()
->getRowArray();
if ($patres) {
// Update existing result
$ok = $db->table('patres')
->where('OrderID', $internalOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->update(['Result' => $value]);
} else {
// Insert new result row
$ok = $db->table('patres')->insert([
'OrderID' => $internalOID,
'TestSiteID' => $testSiteID,
'Result' => $value,
'CreateDate' => date('Y-m-d H:i:s'),
]);
}
if ($ok === false) {
throw new \Exception('SET_RESULT update/insert failed');
}
}
protected function executeAction(array $action, array $context): void protected function executeAction(array $action, array $context): void
{ {
$type = strtoupper((string) ($action['ActionType'] ?? '')); $type = strtoupper((string) ($action['ActionType'] ?? ''));

View File

@ -43,4 +43,141 @@ class RuleExpressionService
return (bool) $this->evaluate($expr, $context); return (bool) $this->evaluate($expr, $context);
} }
/**
* Compile DSL expression to engine-compatible JSON structure.
*
* Supported DSL:
* - if(condition ? action : action)
* - sex('F'|'M') -> order["Sex"] == 'F'
* - set_result(value) -> {"value": value} or {"valueExpr": "value"}
*
* @param string $expr The raw DSL expression
* @return array The compiled structure with valueExpr
* @throws \InvalidArgumentException If DSL is invalid
*/
public function compile(string $expr): array
{
$expr = trim($expr);
if ($expr === '') {
return [];
}
// Remove outer parentheses from if(...)
if (preg_match('/^if\s*\(\s*(.+?)\s*\)$/s', $expr, $m)) {
$expr = trim($m[1]);
}
// Parse: condition ? thenAction : elseAction
if (!preg_match('/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/s', $expr, $parts)) {
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition ? action : action)" format');
}
$condition = trim($parts[1]);
$thenAction = trim($parts[2]);
$elseAction = trim($parts[3]);
// Compile condition
$compiledCondition = $this->compileCondition($condition);
// Compile actions
$thenCompiled = $this->compileAction($thenAction);
$elseCompiled = $this->compileAction($elseAction);
// Build valueExpr combining condition and actions
$thenValue = $thenCompiled['valueExpr'] ?? json_encode($thenCompiled['value'] ?? null);
$elseValue = $elseCompiled['valueExpr'] ?? json_encode($elseCompiled['value'] ?? null);
// Handle string vs numeric values
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 [
'conditionExpr' => $compiledCondition,
'valueExpr' => $valueExpr,
'then' => $thenCompiled,
'else' => $elseCompiled,
];
}
/**
* Compile DSL condition to ExpressionLanguage expression
*/
private function compileCondition(string $condition): string
{
$condition = trim($condition);
// sex('F') -> order["Sex"] == 'F'
if (preg_match("/^sex\s*\(\s*['\"]([MF])['\"]\s*\)$/i", $condition, $m)) {
return 'order["Sex"] == "' . $m[1] . '"';
}
// sex == 'F' (alternative syntax)
if (preg_match('/^\s*sex\s*==\s*[\'"]([MF])[\'"]\s*$/i', $condition, $m)) {
return 'order["Sex"] == "' . $m[1] . '"';
}
// priority('S') -> order["Priority"] == 'S'
if (preg_match("/^priority\s*\(\s*['\"]([SR])['\"]\s*\)$/i", $condition, $m)) {
return 'order["Priority"] == "' . $m[1] . '"';
}
// priority == 'S' (alternative syntax)
if (preg_match('/^\s*priority\s*==\s*[\'"]([SR])[\'"]\s*$/i', $condition, $m)) {
return 'order["Priority"] == "' . $m[1] . '"';
}
// age > 18 -> patient["Age"] > 18 (if available) or order["Age"] > 18
if (preg_match('/^\s*age\s*([<>]=?)\s*(\d+)\s*$/i', $condition, $m)) {
return 'order["Age"] ' . $m[1] . ' ' . $m[2];
}
// If already valid ExpressionLanguage, return as-is
return $condition;
}
/**
* Compile DSL action to action params
*/
private function compileAction(string $action): array
{
$action = trim($action);
// set_result(value) -> SET_RESULT action
if (preg_match('/^set_result\s*\(\s*(.+?)\s*\)$/i', $action, $m)) {
$value = trim($m[1]);
// Check if it's a number
if (is_numeric($value)) {
return [
'type' => 'SET_RESULT',
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
'valueExpr' => $value,
];
}
// Check if it's a quoted string
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
return [
'type' => 'SET_RESULT',
'value' => $vm[1],
'valueExpr' => '"' . addslashes($vm[1]) . '"',
];
}
// Complex expression
return [
'type' => 'SET_RESULT',
'valueExpr' => $value,
];
}
throw new \InvalidArgumentException('Unknown action: ' . $action);
}
} }

View File

@ -33,6 +33,8 @@ tags:
description: Specimen and container management description: Specimen and container management
- name: Tests - name: Tests
description: Test definitions and test catalog description: Test definitions and test catalog
- name: Calculations
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders - name: Orders
description: Laboratory order management description: Laboratory order management
- name: Results - name: Results
@ -54,7 +56,7 @@ tags:
- name: Users - name: Users
description: User management and administration description: User management and administration
- name: Rules - name: Rules
description: Common rule engine (events, conditions, actions) description: Rule engine - rules can be linked to multiple tests via testrule mapping table
paths: paths:
/api/auth/login: /api/auth/login:
post: post:
@ -224,6 +226,44 @@ paths:
responses: responses:
'201': '201':
description: User created description: User created
/api/calc/{codeOrName}:
post:
tags:
- Calculations
summary: Evaluate a configured calculation by test code or name and return the numeric result only.
security: []
parameters:
- name: codeOrName
in: path
required: true
schema:
type: string
description: TestSiteCode or TestSiteName of the calculated test (case-insensitive).
requestBody:
required: true
content:
application/json:
schema:
type: object
description: Key-value pairs where keys match member tests used in the formula.
additionalProperties:
type: number
example:
TBIL: 5
DBIL: 3
responses:
'200':
description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing.
content:
application/json:
schema:
type: object
examples:
success:
value:
IBIL: 2
incomplete:
value: {}
/api/contact: /api/contact:
get: get:
tags: tags:
@ -1051,13 +1091,6 @@ paths:
VER: Verified VER: Verified
REV: Reviewed REV: Reviewed
REP: Reported REP: Reported
- name: include
in: query
schema:
type: string
enum:
- details
description: Include specimens and tests in response
responses: responses:
'200': '200':
description: List of orders description: List of orders
@ -1073,7 +1106,7 @@ paths:
data: data:
type: array type: array
items: items:
$ref: '#/components/schemas/OrderTest' $ref: '#/components/schemas/OrderTestList'
post: post:
tags: tags:
- Orders - Orders
@ -3071,32 +3104,16 @@ paths:
schema: schema:
type: string type: string
description: Filter by event code description: Filter by event code
- name: Active
in: query
schema:
type: integer
enum:
- 0
- 1
description: Filter by active flag
- name: ScopeType
in: query
schema:
type: string
enum:
- GLOBAL
- TESTSITE
description: Filter by scope type
- name: TestSiteID - name: TestSiteID
in: query in: query
schema: schema:
type: integer type: integer
description: Filter by TestSiteID (for TESTSITE scope) description: Filter by TestSiteID (returns rules linked to this test). Rules are only returned when attached to tests.
- name: search - name: search
in: query in: query
schema: schema:
type: string type: string
description: Search by rule name description: Search by rule code or name
responses: responses:
'200': '200':
description: List of rules description: List of rules
@ -3117,6 +3134,9 @@ paths:
tags: tags:
- Rules - Rules
summary: Create rule summary: Create rule
description: |
Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
A single rule can be linked to multiple tests. Rules are active only when attached to tests.
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody: requestBody:
@ -3126,48 +3146,47 @@ paths:
schema: schema:
type: object type: object
properties: properties:
Name: RuleCode:
type: string type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description: Description:
type: string type: string
EventCode: EventCode:
type: string type: string
example: ORDER_CREATED example: ORDER_CREATED
ScopeType: TestSiteIDs:
type: string type: array
enum: items:
- GLOBAL type: integer
- TESTSITE description: Array of TestSiteIDs to link this rule to (required)
TestSiteID: example:
type: integer - 1
nullable: true - 2
- 3
ConditionExpr: ConditionExpr:
type: string type: string
nullable: true nullable: true
Priority: example: order["Priority"] == "S"
type: integer
Active:
type: integer
enum:
- 0
- 1
actions: actions:
type: array type: array
items: items:
type: object type: object
properties: properties:
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT
ActionParams: ActionParams:
oneOf: oneOf:
- type: string - type: string
- type: object - type: object
required: required:
- Name - RuleCode
- RuleName
- EventCode - EventCode
- ScopeType - TestSiteIDs
responses: responses:
'201': '201':
description: Rule created description: Rule created
@ -3175,7 +3194,7 @@ paths:
get: get:
tags: tags:
- Rules - Rules
summary: Get rule (with actions) summary: Get rule with actions and linked tests
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@ -3184,9 +3203,10 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Rule details description: Rule details with actions and linked test sites
content: content:
application/json: application/json:
schema: schema:
@ -3197,13 +3217,17 @@ paths:
message: message:
type: string type: string
data: data:
$ref: '#/components/schemas/RuleWithActions' $ref: '#/components/schemas/RuleWithDetails'
'404': '404':
description: Rule not found description: Rule not found
patch: patch:
tags: tags:
- Rules - Rules
summary: Update rule summary: Update rule
description: |
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
Tests not in the new list will be unlinked, and new tests will be linked.
Rules are active only when attached to tests.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@ -3212,6 +3236,7 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
requestBody: requestBody:
required: true required: true
content: content:
@ -3219,30 +3244,22 @@ paths:
schema: schema:
type: object type: object
properties: properties:
Name: RuleCode:
type: string
RuleName:
type: string type: string
Description: Description:
type: string type: string
EventCode: EventCode:
type: string type: string
ScopeType: TestSiteIDs:
type: string type: array
enum: items:
- GLOBAL type: integer
- TESTSITE description: Array of TestSiteIDs to link this rule to
TestSiteID:
type: integer
nullable: true
ConditionExpr: ConditionExpr:
type: string type: string
nullable: true nullable: true
Priority:
type: integer
Active:
type: integer
enum:
- 0
- 1
responses: responses:
'200': '200':
description: Rule updated description: Rule updated
@ -3260,6 +3277,7 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Rule deleted description: Rule deleted
@ -3289,6 +3307,55 @@ paths:
responses: responses:
'200': '200':
description: Validation result description: Validation result
/api/rules/compile:
post:
tags:
- Rules
summary: Compile DSL expression to engine-compatible structure
description: |
Compile a DSL expression to the engine-compatible JSON structure.
Frontend calls this when user clicks "Compile" button.
Returns compiled structure that can be saved to ConditionExprCompiled field.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
expr:
type: string
description: Raw DSL expression
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
required:
- expr
responses:
'200':
description: Compilation successful
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
data:
type: object
properties:
raw:
type: string
description: Original DSL expression
compiled:
type: object
description: Parsed structure with conditionExpr, valueExpr, then, else
conditionExprCompiled:
type: string
description: JSON string to save to ConditionExprCompiled field
'400':
description: Compilation failed (invalid syntax)
/api/rules/{id}/actions: /api/rules/{id}/actions:
get: get:
tags: tags:
@ -3302,6 +3369,7 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Actions list description: Actions list
@ -3330,6 +3398,7 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
requestBody: requestBody:
required: true required: true
content: content:
@ -3337,10 +3406,9 @@ paths:
schema: schema:
type: object type: object
properties: properties:
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT
ActionParams: ActionParams:
oneOf: oneOf:
- type: string - type: string
@ -3363,11 +3431,13 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
- name: actionId - name: actionId
in: path in: path
required: true required: true
schema: schema:
type: integer type: integer
description: RuleActionID
requestBody: requestBody:
required: true required: true
content: content:
@ -3375,8 +3445,6 @@ paths:
schema: schema:
type: object type: object
properties: properties:
Seq:
type: integer
ActionType: ActionType:
type: string type: string
ActionParams: ActionParams:
@ -3398,11 +3466,13 @@ paths:
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
- name: actionId - name: actionId
in: path in: path
required: true required: true
schema: schema:
type: integer type: integer
description: RuleActionID
responses: responses:
'200': '200':
description: Action deleted description: Action deleted
@ -4433,7 +4503,62 @@ paths:
type: integer type: integer
details: details:
type: object type: object
description: Type-specific details description: |
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
FormulaCode:
type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
Unit1:
type: string
Factor:
type: number
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members:
type: array
description: |
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items:
type: object
properties:
TestSiteID:
type: integer
description: The actual TestSiteID of the member test (required)
required:
- TestSiteID
refnum: refnum:
type: array type: array
items: items:
@ -4451,6 +4576,30 @@ paths:
- TestSiteCode - TestSiteCode
- TestSiteName - TestSiteName
- TestType - TestType
examples:
CALC_test:
summary: Create calculated test with members
value:
SiteID: 1
TestSiteCode: IBIL
TestSiteName: Indirect Bilirubin
TestType: CALC
Description: Bilirubin Indirek
SeqScr: 210
SeqRpt: 210
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: '{TBIL} - {DBIL}'
RefType: RANGE
Unit1: mg/dL
Decimal: 2
members:
- TestSiteID: 22
- TestSiteID: 23
responses: responses:
'201': '201':
description: Test definition created description: Test definition created
@ -4469,6 +4618,19 @@ paths:
properties: properties:
TestSiteId: TestSiteId:
type: integer type: integer
'400':
description: Validation error (e.g., invalid member TestSiteID)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: failed
message:
type: string
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
patch: patch:
tags: tags:
- Tests - Tests
@ -4557,7 +4719,62 @@ paths:
type: integer type: integer
details: details:
type: object type: object
description: Type-specific details description: |
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
FormulaCode:
type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
Unit1:
type: string
Factor:
type: number
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members:
type: array
description: |
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items:
type: object
properties:
TestSiteID:
type: integer
description: The actual TestSiteID of the member test (required)
required:
- TestSiteID
refnum: refnum:
type: array type: array
items: items:
@ -4590,6 +4807,19 @@ paths:
properties: properties:
TestSiteId: TestSiteId:
type: integer type: integer
'400':
description: Validation error (e.g., invalid member TestSiteID)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: failed
message:
type: string
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
/api/test/{id}: /api/test/{id}:
get: get:
tags: tags:
@ -6262,7 +6492,10 @@ components:
type: object type: object
testdefgrp: testdefgrp:
type: array type: array
description: Group members (for GROUP and CALC types) description: |
Group members (for GROUP and CALC types).
When creating or updating, provide members in details.members array with TestSiteID field.
Do NOT use Member or SeqScr fields when creating/updating.
items: items:
type: object type: object
properties: properties:
@ -6274,10 +6507,9 @@ components:
description: Parent group TestSiteID description: Parent group TestSiteID
Member: Member:
type: integer type: integer
description: Member TestSiteID (foreign key to testdefsite) description: |
MemberTestSiteID: Member TestSiteID (foreign key to testdefsite).
type: integer **Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
description: Member's actual TestSiteID (same as Member, for clarity)
TestSiteCode: TestSiteCode:
type: string type: string
description: Member test code description: Member test code
@ -6623,6 +6855,72 @@ components:
type: string type: string
format: date-time format: date-time
description: Soft delete timestamp description: Soft delete timestamp
OrderTestList:
type: object
properties:
InternalOID:
type: integer
description: Internal order ID
OrderID:
type: string
description: Order ID (e.g., 0025030300001)
PlacerID:
type: string
nullable: true
InternalPID:
type: integer
description: Patient internal ID
SiteID:
type: integer
PVADTID:
type: integer
description: Visit ADT ID
ReqApp:
type: string
nullable: true
Priority:
type: string
enum:
- R
- S
- U
description: |
R: Routine
S: Stat
U: Urgent
PriorityLabel:
type: string
description: Priority display text
TrnDate:
type: string
format: date-time
description: Transaction/Order date
EffDate:
type: string
format: date-time
description: Effective date
CreateDate:
type: string
format: date-time
OrderStatus:
type: string
enum:
- ORD
- SCH
- ANA
- VER
- REV
- REP
description: |
ORD: Ordered
SCH: Scheduled
ANA: Analysis
VER: Verified
REV: Reviewed
REP: Reported
OrderStatusLabel:
type: string
description: Order status display text
OrderTest: OrderTest:
type: object type: object
properties: properties:
@ -7071,31 +7369,28 @@ components:
properties: properties:
RuleID: RuleID:
type: integer type: integer
Name: RuleCode:
type: string type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description: Description:
type: string type: string
nullable: true nullable: true
EventCode: EventCode:
type: string type: string
ScopeType: example: ORDER_CREATED
type: string
enum:
- GLOBAL
- TESTSITE
TestSiteID:
type: integer
nullable: true
ConditionExpr: ConditionExpr:
type: string type: string
nullable: true nullable: true
Priority: description: Raw DSL expression (editable)
type: integer example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
Active: ConditionExprCompiled:
type: integer type: string
enum: nullable: true
- 0 description: Compiled JSON structure (auto-generated from ConditionExpr)
- 1 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"}}'
CreateDate: CreateDate:
type: string type: string
format: date-time format: date-time
@ -7115,8 +7410,6 @@ components:
type: integer type: integer
RuleID: RuleID:
type: integer type: integer
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT example: SET_RESULT
@ -7124,6 +7417,7 @@ components:
type: string type: string
description: JSON string parameters description: JSON string parameters
nullable: true nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate: CreateDate:
type: string type: string
format: date-time format: date-time
@ -7132,7 +7426,7 @@ components:
type: string type: string
format: date-time format: date-time
nullable: true nullable: true
RuleWithActions: RuleWithDetails:
allOf: allOf:
- $ref: '#/components/schemas/RuleDef' - $ref: '#/components/schemas/RuleDef'
- type: object - type: object
@ -7141,6 +7435,29 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/RuleAction' $ref: '#/components/schemas/RuleAction'
linkedTests:
type: array
items:
type: integer
description: Array of TestSiteIDs this rule is linked to. Rules are active only when attached to tests.
TestRule:
type: object
description: Mapping between a rule and a test site (testrule table). Rules are active when linked via this table.
properties:
TestRuleID:
type: integer
RuleID:
type: integer
TestSiteID:
type: integer
CreateDate:
type: string
format: date-time
nullable: true
EndDate:
type: string
format: date-time
nullable: true
Contact: Contact:
type: object type: object
properties: properties:
@ -7263,16 +7580,54 @@ components:
type: string type: string
description: Test name description: Test name
nullable: true nullable: true
TestType:
type: string
description: Test type code identifying the test category
enum:
- TEST
- PARAM
- CALC
- GROUP
- TITLE
SID: SID:
type: string type: string
description: Order ID reference description: Order ID reference
SampleID: SampleID:
type: string type: string
description: Sample ID (same as OrderID) description: Sample ID (same as OrderID)
SeqScr:
type: integer
nullable: true
description: Sequence number for this test on the screen
SeqRpt:
type: integer
nullable: true
description: Sequence number for this test in reports
Result: Result:
type: string type: string
description: Test result value description: Test result value
nullable: true nullable: true
Discipline:
type: object
description: Discipline metadata used for ordering tests
properties:
DisciplineID:
type: integer
nullable: true
DisciplineCode:
type: string
nullable: true
DisciplineName:
type: string
nullable: true
SeqScr:
type: integer
nullable: true
description: Discipline sequence on the screen
SeqRpt:
type: integer
nullable: true
description: Discipline sequence in reports
ResultDateTime: ResultDateTime:
type: string type: string
format: date-time format: date-time

View File

@ -35,6 +35,8 @@ tags:
description: Specimen and container management description: Specimen and container management
- name: Tests - name: Tests
description: Test definitions and test catalog description: Test definitions and test catalog
- name: Calculations
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders - name: Orders
description: Laboratory order management description: Laboratory order management
- name: Results - name: Results
@ -56,7 +58,7 @@ tags:
- name: Users - name: Users
description: User management and administration description: User management and administration
- name: Rules - name: Rules
description: Common rule engine (events, conditions, actions) description: Rule engine - rules can be linked to multiple tests via testrule mapping table
components: components:
securitySchemes: securitySchemes:
@ -145,6 +147,8 @@ components:
$ref: './components/schemas/tests.yaml#/TestMap' $ref: './components/schemas/tests.yaml#/TestMap'
# Orders schemas # Orders schemas
OrderTestList:
$ref: './components/schemas/orders.yaml#/OrderTestList'
OrderTest: OrderTest:
$ref: './components/schemas/orders.yaml#/OrderTest' $ref: './components/schemas/orders.yaml#/OrderTest'
OrderItem: OrderItem:
@ -189,8 +193,10 @@ components:
$ref: './components/schemas/rules.yaml#/RuleDef' $ref: './components/schemas/rules.yaml#/RuleDef'
RuleAction: RuleAction:
$ref: './components/schemas/rules.yaml#/RuleAction' $ref: './components/schemas/rules.yaml#/RuleAction'
RuleWithActions: RuleWithDetails:
$ref: './components/schemas/rules.yaml#/RuleWithActions' $ref: './components/schemas/rules.yaml#/RuleWithDetails'
TestRule:
$ref: './components/schemas/rules.yaml#/TestRule' # Mapping table between rules and tests
# Paths are in separate files in the paths/ directory # Paths are in separate files in the paths/ directory
# To view the complete API with all paths, use: api-docs.bundled.yaml # To view the complete API with all paths, use: api-docs.bundled.yaml

View File

@ -1,3 +1,61 @@
OrderTestList:
type: object
properties:
InternalOID:
type: integer
description: Internal order ID
OrderID:
type: string
description: Order ID (e.g., 0025030300001)
PlacerID:
type: string
nullable: true
InternalPID:
type: integer
description: Patient internal ID
SiteID:
type: integer
PVADTID:
type: integer
description: Visit ADT ID
ReqApp:
type: string
nullable: true
Priority:
type: string
enum: [R, S, U]
description: |
R: Routine
S: Stat
U: Urgent
PriorityLabel:
type: string
description: Priority display text
TrnDate:
type: string
format: date-time
description: Transaction/Order date
EffDate:
type: string
format: date-time
description: Effective date
CreateDate:
type: string
format: date-time
OrderStatus:
type: string
enum: [ORD, SCH, ANA, VER, REV, REP]
description: |
ORD: Ordered
SCH: Scheduled
ANA: Analysis
VER: Verified
REV: Reviewed
REP: Reported
OrderStatusLabel:
type: string
description: Order status display text
OrderTest: OrderTest:
type: object type: object
properties: properties:
@ -132,16 +190,49 @@ OrderTestItem:
type: string type: string
description: Test name description: Test name
nullable: true nullable: true
TestType:
type: string
description: Test type code identifying the test category
enum: [TEST, PARAM, CALC, GROUP, TITLE]
SID: SID:
type: string type: string
description: Order ID reference description: Order ID reference
SampleID: SampleID:
type: string type: string
description: Sample ID (same as OrderID) description: Sample ID (same as OrderID)
SeqScr:
type: integer
nullable: true
description: Sequence number for this test on the screen
SeqRpt:
type: integer
nullable: true
description: Sequence number for this test in reports
Result: Result:
type: string type: string
description: Test result value description: Test result value
nullable: true nullable: true
Discipline:
type: object
description: Discipline metadata used for ordering tests
properties:
DisciplineID:
type: integer
nullable: true
DisciplineCode:
type: string
nullable: true
DisciplineName:
type: string
nullable: true
SeqScr:
type: integer
nullable: true
description: Discipline sequence on the screen
SeqRpt:
type: integer
nullable: true
description: Discipline sequence in reports
ResultDateTime: ResultDateTime:
type: string type: string
format: date-time format: date-time

View File

@ -3,27 +3,28 @@ RuleDef:
properties: properties:
RuleID: RuleID:
type: integer type: integer
Name: RuleCode:
type: string type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description: Description:
type: string type: string
nullable: true nullable: true
EventCode: EventCode:
type: string type: string
ScopeType: example: ORDER_CREATED
type: string
enum: [GLOBAL, TESTSITE]
TestSiteID:
type: integer
nullable: true
ConditionExpr: ConditionExpr:
type: string type: string
nullable: true nullable: true
Priority: description: Raw DSL expression (editable)
type: integer example: "if(sex('F') ? set_result(0.7) : set_result(1))"
Active: ConditionExprCompiled:
type: integer type: string
enum: [0, 1] nullable: true
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"}}'
CreateDate: CreateDate:
type: string type: string
format: date-time format: date-time
@ -44,8 +45,6 @@ RuleAction:
type: integer type: integer
RuleID: RuleID:
type: integer type: integer
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT example: SET_RESULT
@ -53,6 +52,7 @@ RuleAction:
type: string type: string
description: JSON string parameters description: JSON string parameters
nullable: true nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate: CreateDate:
type: string type: string
format: date-time format: date-time
@ -62,7 +62,7 @@ RuleAction:
format: date-time format: date-time
nullable: true nullable: true
RuleWithActions: RuleWithDetails:
allOf: allOf:
- $ref: './rules.yaml#/RuleDef' - $ref: './rules.yaml#/RuleDef'
- type: object - type: object
@ -71,3 +71,27 @@ RuleWithActions:
type: array type: array
items: items:
$ref: './rules.yaml#/RuleAction' $ref: './rules.yaml#/RuleAction'
linkedTests:
type: array
items:
type: integer
description: Array of TestSiteIDs this rule is linked to. Rules are active only when attached to tests.
TestRule:
type: object
description: Mapping between a rule and a test site (testrule table). Rules are active when linked via this table.
properties:
TestRuleID:
type: integer
RuleID:
type: integer
TestSiteID:
type: integer
CreateDate:
type: string
format: date-time
nullable: true
EndDate:
type: string
format: date-time
nullable: true

View File

@ -141,7 +141,10 @@ TestDefinition:
type: object type: object
testdefgrp: testdefgrp:
type: array type: array
description: Group members (for GROUP and CALC types) description: |
Group members (for GROUP and CALC types).
When creating or updating, provide members in details.members array with TestSiteID field.
Do NOT use Member or SeqScr fields when creating/updating.
items: items:
type: object type: object
properties: properties:
@ -153,10 +156,9 @@ TestDefinition:
description: Parent group TestSiteID description: Parent group TestSiteID
Member: Member:
type: integer type: integer
description: Member TestSiteID (foreign key to testdefsite) description: |
MemberTestSiteID: Member TestSiteID (foreign key to testdefsite).
type: integer **Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
description: Member's actual TestSiteID (same as Member, for clarity)
TestSiteCode: TestSiteCode:
type: string type: string
description: Member test code description: Member test code

37
public/paths/calc.yaml Normal file
View File

@ -0,0 +1,37 @@
/api/calc/{codeOrName}:
post:
tags: [Calculations]
summary: Evaluate a configured calculation by test code or name and return the numeric result only.
security: []
parameters:
- name: codeOrName
in: path
required: true
schema:
type: string
description: TestSiteCode or TestSiteName of the calculated test (case-insensitive).
requestBody:
required: true
content:
application/json:
schema:
type: object
description: Key-value pairs where keys match member tests used in the formula.
additionalProperties:
type: number
example:
TBIL: 5
DBIL: 3
responses:
'200':
description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing.
content:
application/json:
schema:
type: object
examples:
success:
value:
IBIL: 2.0
incomplete:
value: {}

View File

@ -30,12 +30,6 @@
VER: Verified VER: Verified
REV: Reviewed REV: Reviewed
REP: Reported REP: Reported
- name: include
in: query
schema:
type: string
enum: [details]
description: Include specimens and tests in response
responses: responses:
'200': '200':
description: List of orders description: List of orders
@ -51,7 +45,7 @@
data: data:
type: array type: array
items: items:
$ref: '../components/schemas/orders.yaml#/OrderTest' $ref: '../components/schemas/orders.yaml#/OrderTestList'
post: post:
tags: [Orders] tags: [Orders]

View File

@ -10,28 +10,16 @@
schema: schema:
type: string type: string
description: Filter by event code description: Filter by event code
- name: Active
in: query
schema:
type: integer
enum: [0, 1]
description: Filter by active flag
- name: ScopeType
in: query
schema:
type: string
enum: [GLOBAL, TESTSITE]
description: Filter by scope type
- name: TestSiteID - name: TestSiteID
in: query in: query
schema: schema:
type: integer type: integer
description: Filter by TestSiteID (for TESTSITE scope) description: Filter by TestSiteID (returns rules linked to this test). Rules are only returned when attached to tests.
- name: search - name: search
in: query in: query
schema: schema:
type: string type: string
description: Search by rule name description: Search by rule code or name
responses: responses:
'200': '200':
description: List of rules description: List of rules
@ -52,6 +40,9 @@
post: post:
tags: [Rules] tags: [Rules]
summary: Create rule summary: Create rule
description: |
Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
A single rule can be linked to multiple tests. Rules are active only when attached to tests.
security: security:
- bearerAuth: [] - bearerAuth: []
requestBody: requestBody:
@ -61,41 +52,40 @@
schema: schema:
type: object type: object
properties: properties:
Name: RuleCode:
type: string type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description: Description:
type: string type: string
EventCode: EventCode:
type: string type: string
example: ORDER_CREATED example: ORDER_CREATED
ScopeType: TestSiteIDs:
type: string type: array
enum: [GLOBAL, TESTSITE] items:
TestSiteID: type: integer
type: integer description: Array of TestSiteIDs to link this rule to (required)
nullable: true example: [1, 2, 3]
ConditionExpr: ConditionExpr:
type: string type: string
nullable: true nullable: true
Priority: example: 'order["Priority"] == "S"'
type: integer
Active:
type: integer
enum: [0, 1]
actions: actions:
type: array type: array
items: items:
type: object type: object
properties: properties:
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT
ActionParams: ActionParams:
oneOf: oneOf:
- type: string - type: string
- type: object - type: object
required: [Name, EventCode, ScopeType] required: [RuleCode, RuleName, EventCode, TestSiteIDs]
responses: responses:
'201': '201':
description: Rule created description: Rule created
@ -103,7 +93,7 @@
/api/rules/{id}: /api/rules/{id}:
get: get:
tags: [Rules] tags: [Rules]
summary: Get rule (with actions) summary: Get rule with actions and linked tests
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@ -112,9 +102,10 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Rule details description: Rule details with actions and linked test sites
content: content:
application/json: application/json:
schema: schema:
@ -125,13 +116,17 @@
message: message:
type: string type: string
data: data:
$ref: '../components/schemas/rules.yaml#/RuleWithActions' $ref: '../components/schemas/rules.yaml#/RuleWithDetails'
'404': '404':
description: Rule not found description: Rule not found
patch: patch:
tags: [Rules] tags: [Rules]
summary: Update rule summary: Update rule
description: |
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
Tests not in the new list will be unlinked, and new tests will be linked.
Rules are active only when attached to tests.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@ -140,6 +135,7 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
requestBody: requestBody:
required: true required: true
content: content:
@ -147,14 +143,16 @@
schema: schema:
type: object type: object
properties: properties:
Name: { type: string } RuleCode: { type: string }
RuleName: { type: string }
Description: { type: string } Description: { type: string }
EventCode: { type: string } EventCode: { type: string }
ScopeType: { type: string, enum: [GLOBAL, TESTSITE] } TestSiteIDs:
TestSiteID: { type: integer, nullable: true } type: array
items:
type: integer
description: Array of TestSiteIDs to link this rule to
ConditionExpr: { type: string, nullable: true } ConditionExpr: { type: string, nullable: true }
Priority: { type: integer }
Active: { type: integer, enum: [0, 1] }
responses: responses:
'200': '200':
description: Rule updated description: Rule updated
@ -172,6 +170,7 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Rule deleted description: Rule deleted
@ -201,6 +200,54 @@
'200': '200':
description: Validation result description: Validation result
/api/rules/compile:
post:
tags: [Rules]
summary: Compile DSL expression to engine-compatible structure
description: |
Compile a DSL expression to the engine-compatible JSON structure.
Frontend calls this when user clicks "Compile" button.
Returns compiled structure that can be saved to ConditionExprCompiled field.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
expr:
type: string
description: Raw DSL expression
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
required: [expr]
responses:
'200':
description: Compilation successful
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
data:
type: object
properties:
raw:
type: string
description: Original DSL expression
compiled:
type: object
description: Parsed structure with conditionExpr, valueExpr, then, else
conditionExprCompiled:
type: string
description: JSON string to save to ConditionExprCompiled field
'400':
description: Compilation failed (invalid syntax)
/api/rules/{id}/actions: /api/rules/{id}/actions:
get: get:
tags: [Rules] tags: [Rules]
@ -213,6 +260,7 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
responses: responses:
'200': '200':
description: Actions list description: Actions list
@ -241,6 +289,7 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
requestBody: requestBody:
required: true required: true
content: content:
@ -248,10 +297,9 @@
schema: schema:
type: object type: object
properties: properties:
Seq:
type: integer
ActionType: ActionType:
type: string type: string
example: SET_RESULT
ActionParams: ActionParams:
oneOf: oneOf:
- type: string - type: string
@ -273,11 +321,13 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
- name: actionId - name: actionId
in: path in: path
required: true required: true
schema: schema:
type: integer type: integer
description: RuleActionID
requestBody: requestBody:
required: true required: true
content: content:
@ -285,7 +335,6 @@
schema: schema:
type: object type: object
properties: properties:
Seq: { type: integer }
ActionType: { type: string } ActionType: { type: string }
ActionParams: ActionParams:
oneOf: oneOf:
@ -306,11 +355,13 @@
required: true required: true
schema: schema:
type: integer type: integer
description: RuleID
- name: actionId - name: actionId
in: path in: path
required: true required: true
schema: schema:
type: integer type: integer
description: RuleActionID
responses: responses:
'200': '200':
description: Action deleted description: Action deleted

View File

@ -141,7 +141,52 @@
type: integer type: integer
details: details:
type: object type: object
description: Type-specific details description: |
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
RefType:
type: string
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
FormulaCode:
type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
Unit1:
type: string
Factor:
type: number
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members:
type: array
description: |
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items:
type: object
properties:
TestSiteID:
type: integer
description: The actual TestSiteID of the member test (required)
required:
- TestSiteID
refnum: refnum:
type: array type: array
items: items:
@ -159,6 +204,30 @@
- TestSiteCode - TestSiteCode
- TestSiteName - TestSiteName
- TestType - TestType
examples:
CALC_test:
summary: Create calculated test with members
value:
SiteID: 1
TestSiteCode: IBIL
TestSiteName: Indirect Bilirubin
TestType: CALC
Description: Bilirubin Indirek
SeqScr: 210
SeqRpt: 210
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
details:
DisciplineID: 2
DepartmentID: 2
FormulaCode: "{TBIL} - {DBIL}"
RefType: RANGE
Unit1: mg/dL
Decimal: 2
members:
- TestSiteID: 22
- TestSiteID: 23
responses: responses:
'201': '201':
description: Test definition created description: Test definition created
@ -177,6 +246,19 @@
properties: properties:
TestSiteId: TestSiteId:
type: integer type: integer
'400':
description: Validation error (e.g., invalid member TestSiteID)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: failed
message:
type: string
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
patch: patch:
tags: [Tests] tags: [Tests]
@ -250,7 +332,52 @@
type: integer type: integer
details: details:
type: object type: object
description: Type-specific details description: |
Type-specific details. For CALC and GROUP types, include members array.
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
Invalid TestSiteIDs will result in a 400 error.
properties:
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
RefType:
type: string
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
FormulaCode:
type: string
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
Unit1:
type: string
Factor:
type: number
Unit2:
type: string
Decimal:
type: integer
default: 2
Method:
type: string
ExpectedTAT:
type: integer
members:
type: array
description: |
Array of member tests for CALC and GROUP types.
Each member object must contain `TestSiteID` (the actual test ID).
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
items:
type: object
properties:
TestSiteID:
type: integer
description: The actual TestSiteID of the member test (required)
required:
- TestSiteID
refnum: refnum:
type: array type: array
items: items:
@ -283,6 +410,19 @@
properties: properties:
TestSiteId: TestSiteId:
type: integer type: integer
'400':
description: Validation error (e.g., invalid member TestSiteID)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: failed
message:
type: string
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
/api/test/{id}: /api/test/{id}:
get: get:

View File

@ -0,0 +1,148 @@
<?php
namespace Tests\Feature\Calculator;
use App\Models\Test\TestDefCalModel;
use App\Models\Test\TestDefSiteModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class CalculatorEndpointTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected TestDefSiteModel $siteModel;
protected TestDefCalModel $calcModel;
protected ?int $siteId = null;
protected ?int $calcId = null;
protected string $calcName;
protected string $calcCode;
protected function setUp(): void
{
parent::setUp();
$this->siteModel = new TestDefSiteModel();
$this->calcModel = new TestDefCalModel();
$this->calcName = 'API Calc ' . uniqid();
$this->calcCode = $this->generateUniqueCalcCode();
$siteId = $this->siteModel->insert([
'SiteID' => 1,
'TestSiteCode' => $this->calcCode,
'TestSiteName' => $this->calcName,
'TestType' => 'CALC',
'ResultType' => 'NMRIC',
'RefType' => 'RANGE',
'Unit1' => 'mg/dL',
'VisibleScr' => 1,
'VisibleRpt' => 1,
'CountStat' => 0,
'CreateDate' => date('Y-m-d H:i:s'),
'StartDate' => date('Y-m-d H:i:s'),
]);
$this->assertNotFalse($siteId, 'Failed to insert testdefsite');
$this->siteId = $siteId;
$this->calcId = $this->calcModel->insert([
'TestSiteID' => $siteId,
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaCode' => 'TBIL - DBIL',
'RefType' => 'RANGE',
'Unit1' => 'mg/dL',
'Factor' => 1,
'Decimal' => 2,
'CreateDate' => date('Y-m-d H:i:s'),
]);
$this->assertNotFalse($this->calcId, 'Failed to insert testdefcal');
$this->assertNotNull($this->calcModel->findActiveByCodeOrName($this->calcCode));
}
protected function tearDown(): void
{
if ($this->calcId) {
$this->calcModel->delete($this->calcId);
}
if ($this->siteId) {
$this->siteModel->delete($this->siteId);
}
parent::tearDown();
}
public function testCalculateByCodeReturnsValue()
{
$response = $this->postCalc($this->calcCode, ['TBIL' => 5, 'DBIL' => 3]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertArrayHasKey($this->calcCode, $data);
$this->assertEquals(2.0, $data[$this->calcCode]);
}
public function testCalculateByNameReturnsValue()
{
$response = $this->postCalc($this->calcName, ['TBIL' => 4, 'DBIL' => 1]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertArrayHasKey($this->calcCode, $data);
$this->assertEquals(3.0, $data[$this->calcCode]);
}
public function testIncompletePayloadReturnsEmptyObject()
{
$response = $this->postCalc($this->calcCode, ['TBIL' => 5]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertSame([], $data);
}
public function testUnknownCalculatorReturnsEmptyObject()
{
$response = $this->postCalc('UNKNOWN_CALC', ['TBIL' => 3, 'DBIL' => 1]);
$response->assertStatus(200);
$data = $this->decodeResponse($response);
$this->assertSame([], $data);
}
private function postCalc(string $identifier, array $payload)
{
return $this->withHeaders(['Content-Type' => 'application/json'])
->withBody(json_encode($payload))
->call('post', 'api/calc/' . rawurlencode($identifier));
}
private function decodeResponse($response): array
{
$json = $response->getJSON();
if (empty($json)) {
return [];
}
return json_decode($json, true) ?: [];
}
private function generateUniqueCalcCode(): string
{
$tries = 0;
do {
$code = 'TC' . strtoupper(bin2hex(random_bytes(2)));
$exists = $this->siteModel->where('TestSiteCode', $code)
->where('EndDate IS NULL')
->first();
} while ($exists && ++$tries < 20);
return $code;
}
}

View File

@ -14,66 +14,7 @@ class OrderCreateTest extends CIUnitTestCase
public function testCreateOrderSuccess() public function testCreateOrderSuccess()
{ {
$faker = Factory::create('id_ID'); $internalPID = $this->createOrderTestPatient();
// First create a patient using the same approach as PatientCreateTest
$patientPayload = [
"PatientID" => "ORD" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"AlternatePID" => "DMY" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"Prefix" => $faker->title,
"NameFirst" => "Order",
"NameMiddle" => $faker->firstName,
"NameMaiden" => $faker->firstName,
"NameLast" => "Test",
"Suffix" => "S.Kom",
"NameAlias" => $faker->userName,
"Sex" => $faker->numberBetween(5, 6),
"PlaceOfBirth" => $faker->city,
"Birthdate" => "1990-01-01",
"ZIP" => $faker->postcode,
"Street_1" => $faker->streetAddress,
"Street_2" => "RT " . $faker->numberBetween(1, 10) . " RW " . $faker->numberBetween(1, 10),
"Street_3" => "Blok " . $faker->numberBetween(1, 20),
"City" => $faker->city,
"Province" => $faker->state,
"EmailAddress1" => "A" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"EmailAddress2" => "B" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"Phone" => $faker->numerify('08##########'),
"MobilePhone" => $faker->numerify('08##########'),
"Race" => (string) $faker->numberBetween(175, 205),
"Country" => (string) $faker->numberBetween(221, 469),
"MaritalStatus" => (string) $faker->numberBetween(8, 15),
"Religion" => (string) $faker->numberBetween(206, 212),
"Ethnic" => (string) $faker->numberBetween(213, 220),
"Citizenship" => "WNI",
"DeathIndicator" => (string) $faker->numberBetween(16, 17),
"LinkTo" => (string) $faker->numberBetween(2, 3),
"Custodian" => 1,
"PatIdt" => [
"IdentifierType" => "KTP",
"Identifier" => $faker->nik() ?? $faker->numerify('################')
],
"PatAtt" => [
[ "Address" => "/api/upload/" . $faker->uuid . ".jpg" ]
],
"PatCom" => $faker->sentence,
];
if($patientPayload['DeathIndicator'] == '16') {
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
} else {
$patientPayload['DeathDateTime'] = null;
}
$patientResult = $this->withBodyFormat('json')->call('post', 'api/patient', $patientPayload);
// Check patient creation succeeded
$patientResult->assertStatus(201);
$patientBody = json_decode($patientResult->getBody(), true);
$internalPID = $patientBody['data']['InternalPID'] ?? null;
$this->assertNotNull($internalPID, 'Failed to create test patient. Response: ' . print_r($patientBody, true));
// Get available tests from testdefsite // Get available tests from testdefsite
$testsResult = $this->call('get', 'api/test'); $testsResult = $this->call('get', 'api/test');
@ -127,9 +68,11 @@ class OrderCreateTest extends CIUnitTestCase
$result->assertStatus(400); $result->assertStatus(400);
$body = json_decode($result->getBody(), true); $body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body); $this->assertIsArray($body);
$this->assertArrayHasKey('errors', $body); $messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
} }
public function testCreateOrderFailsWithInvalidPatient() public function testCreateOrderFailsWithInvalidPatient()
@ -145,34 +88,16 @@ class OrderCreateTest extends CIUnitTestCase
$result->assertStatus(400); $result->assertStatus(400);
$body = json_decode($result->getBody(), true); $body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body); $this->assertIsArray($body);
$this->assertArrayHasKey('errors', $body); $messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
} }
public function testCreateOrderWithMultipleTests() public function testCreateOrderWithMultipleTests()
{ {
$faker = Factory::create('id_ID'); $internalPID = $this->createOrderTestPatient();
// First create a patient
$patientPayload = [
"PatientID" => "ORDM" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000),
"NameFirst" => "Multi",
"NameLast" => "Test",
"Sex" => "2",
"Birthdate" => "1985-05-15",
"PatIdt" => [
"IdentifierType" => "KTP",
"Identifier" => $faker->numerify('################')
]
];
$patientResult = $this->withBodyFormat('json')->call('post', 'api/patient', $patientPayload);
$patientBody = json_decode($patientResult->getBody(), true);
$internalPID = $patientBody['data']['InternalPID'] ?? null;
$this->assertNotNull($internalPID, 'Failed to create test patient');
// Get available tests // Get available tests
$testsResult = $this->call('get', 'api/test'); $testsResult = $this->call('get', 'api/test');
@ -207,4 +132,154 @@ class OrderCreateTest extends CIUnitTestCase
$this->assertGreaterThanOrEqual(1, count($body['data']['Specimens']), 'Should have at least one specimen'); $this->assertGreaterThanOrEqual(1, count($body['data']['Specimens']), 'Should have at least one specimen');
$this->assertGreaterThanOrEqual(2, count($body['data']['Tests']), 'Should have at least two tests'); $this->assertGreaterThanOrEqual(2, count($body['data']['Tests']), 'Should have at least two tests');
} }
public function testOrderShowIncludesDisciplineAndSequenceOrdering()
{
$internalPID = $this->createOrderTestPatient();
$testSiteIDs = $this->collectTestSiteIDs(2);
$orderID = $this->createOrderWithTests($internalPID, $testSiteIDs);
$response = $this->call('get', $this->endpoint . '/' . $orderID);
$response->assertStatus(200);
$body = json_decode($response->getBody(), true);
$this->assertEquals('success', $body['status']);
$tests = $body['data']['Tests'] ?? [];
$this->assertNotEmpty($tests, 'Tests payload should not be empty');
$lastKey = null;
foreach ($tests as $test) {
$this->assertArrayHasKey('Discipline', $test);
$this->assertArrayHasKey('TestType', $test);
$this->assertNotEmpty($test['TestType'], 'Each test should report a test type');
$this->assertArrayHasKey('SeqScr', $test);
$this->assertArrayHasKey('SeqRpt', $test);
$discipline = $test['Discipline'];
$this->assertArrayHasKey('DisciplineID', $discipline);
$this->assertArrayHasKey('DisciplineName', $discipline);
$this->assertArrayHasKey('SeqScr', $discipline);
$this->assertArrayHasKey('SeqRpt', $discipline);
$currentKey = $this->buildTestSortKey($test);
if ($lastKey !== null) {
$this->assertGreaterThanOrEqual($lastKey, $currentKey, 'Tests are not ordered by discipline/test sequence');
}
$lastKey = $currentKey;
}
}
private function createOrderTestPatient(): int
{
$faker = Factory::create('id_ID');
$patientPayload = [
"PatientID" => "ORD" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"AlternatePID" => "DMY" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
"Prefix" => $faker->title,
"NameFirst" => "Order",
"NameMiddle" => $faker->firstName,
"NameMaiden" => $faker->firstName,
"NameLast" => "Test",
"Suffix" => "S.Kom",
"NameAlias" => $faker->userName,
"Sex" => $faker->numberBetween(5, 6),
"PlaceOfBirth" => $faker->city,
"Birthdate" => "1990-01-01",
"ZIP" => $faker->postcode,
"Street_1" => $faker->streetAddress,
"Street_2" => "RT " . $faker->numberBetween(1, 10) . " RW " . $faker->numberBetween(1, 10),
"Street_3" => "Blok " . $faker->numberBetween(1, 20),
"City" => $faker->city,
"Province" => $faker->state,
"EmailAddress1" => "A" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"EmailAddress2" => "B" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
"Phone" => $faker->numerify('08##########'),
"MobilePhone" => $faker->numerify('08##########'),
"Race" => (string) $faker->numberBetween(175, 205),
"Country" => (string) $faker->numberBetween(221, 469),
"MaritalStatus" => (string) $faker->numberBetween(8, 15),
"Religion" => (string) $faker->numberBetween(206, 212),
"Ethnic" => (string) $faker->numberBetween(213, 220),
"Citizenship" => "WNI",
"DeathIndicator" => (string) $faker->numberBetween(16, 17),
"LinkTo" => (string) $faker->numberBetween(2, 3),
"Custodian" => 1,
"PatIdt" => [
"IdentifierType" => "KTP",
"Identifier" => $faker->nik() ?? $faker->numerify('################')
],
"PatAtt" => [
[ "Address" => "/api/upload/" . $faker->uuid . ".jpg" ]
],
"PatCom" => $faker->sentence,
];
if ($patientPayload['DeathIndicator'] == '16') {
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
} else {
$patientPayload['DeathDateTime'] = null;
}
$patientModel = new \App\Models\Patient\PatientModel();
$internalPID = $patientModel->createPatient($patientPayload);
$this->assertNotNull($internalPID, 'Failed to create test patient. Response: ' . print_r($patientPayload, true));
return $internalPID;
}
private function createOrderWithTests(int $internalPID, array $testSiteIDs, string $priority = 'R'): string
{
$payload = [
'InternalPID' => $internalPID,
'Priority' => $priority,
'Tests' => array_map(fn ($testSiteID) => ['TestSiteID' => $testSiteID], $testSiteIDs),
];
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
$result->assertStatus(201);
$body = json_decode($result->getBody(), true);
$this->assertEquals('success', $body['status']);
$orderID = $body['data']['OrderID'] ?? null;
$this->assertNotNull($orderID, 'Order creation response is missing OrderID');
return $orderID;
}
private function collectTestSiteIDs(int $count = 2): array
{
$response = $this->call('get', 'api/test');
$body = json_decode($response->getBody(), true);
$availableTests = $body['data'] ?? [];
if (count($availableTests) < $count) {
$this->markTestSkipped('Need at least ' . $count . ' tests to validate ordering.');
}
$ids = array_values(array_filter(array_column($availableTests, 'TestSiteID')));
return array_slice($ids, 0, $count);
}
private function buildTestSortKey(array $test): string
{
$discipline = $test['Discipline'] ?? [];
$discSeqScr = $this->normalizeSequenceValue($discipline['SeqScr'] ?? null);
$discSeqRpt = $this->normalizeSequenceValue($discipline['SeqRpt'] ?? null);
$testSeqScr = $this->normalizeSequenceValue($test['SeqScr'] ?? null);
$testSeqRpt = $this->normalizeSequenceValue($test['SeqRpt'] ?? null);
$resultID = isset($test['ResultID']) ? (int)$test['ResultID'] : 0;
return sprintf('%06d-%06d-%06d-%06d-%010d', $discSeqScr, $discSeqRpt, $testSeqScr, $testSeqRpt, $resultID);
}
private function normalizeSequenceValue($value): int
{
if (is_numeric($value)) {
return (int)$value;
}
return 999999;
}
} }

View File

@ -0,0 +1,271 @@
<?php
namespace Tests\Unit\Rule;
use App\Models\Rule\RuleDefModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
class RuleDefModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $model;
protected $seed = \App\Database\Seeds\TestSeeder::class;
protected function setUp(): void
{
parent::setUp();
$this->model = new RuleDefModel();
}
/**
* Test that getActiveByEvent returns empty array when TestSiteID is null
* This ensures rules are standalone and must be explicitly included by test
*/
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
{
$rules = $this->model->getActiveByEvent('ORDER_CREATED', null);
$this->assertIsArray($rules);
$this->assertEmpty($rules);
}
/**
* Test that a rule can be linked to multiple tests
*/
public function testRuleCanBeLinkedToMultipleTests(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule
$ruleData = [
'RuleCode' => 'MULTI_TEST_RULE',
'RuleName' => 'Multi Test Rule',
'EventCode' => 'ORDER_CREATED',
'ConditionExpr' => 'order["InternalOID"] > 0',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
// Link rule to both tests
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both test sites
$rules1 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1);
$this->assertNotEmpty($rules1);
$this->assertCount(1, $rules1);
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
$rules2 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2);
$this->assertNotEmpty($rules2);
$this->assertCount(1, $rules2);
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that rules only work when explicitly linked to a test
*/
public function testRulesOnlyWorkWhenExplicitlyLinked(): void
{
$db = \Config\Database::connect();
// Get an existing test
$test = $db->table('testdefsite')
->where('EndDate', null)
->limit(1)
->get()
->getRowArray();
if (!$test) {
$this->markTestSkipped('No tests available in testdefsite table');
}
$testSiteID = (int) $test['TestSiteID'];
// Create a rule (not linked to any test yet)
$ruleData = [
'RuleCode' => 'UNLINKED_RULE',
'RuleName' => 'Unlinked Test Rule',
'EventCode' => 'ORDER_CREATED',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
// Verify rule is NOT returned when not linked
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$this->assertEmpty($rules);
// Now link the rule
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is now returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$this->assertNotEmpty($rules);
$this->assertCount(1, $rules);
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that unlinking a test removes the rule for that test
*/
public function testUnlinkingTestRemovesRule(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule and link to both tests
$ruleData = [
'RuleCode' => 'UNLINK_TEST',
'RuleName' => 'Unlink Test Rule',
'EventCode' => 'ORDER_CREATED',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Verify rule is returned for both
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
// Unlink from first test
$this->model->unlinkTest($ruleID, $testSiteID1);
// Verify rule is NOT returned for first test but still for second
$this->assertEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
// Cleanup
$this->model->delete($ruleID);
}
/**
* Test that deleted (soft deleted) rules are not returned
*/
public function testDeletedRulesAreNotReturned(): void
{
$db = \Config\Database::connect();
$test = $db->table('testdefsite')
->where('EndDate', null)
->limit(1)
->get()
->getRowArray();
if (!$test) {
$this->markTestSkipped('No tests available in testdefsite table');
}
$testSiteID = (int) $test['TestSiteID'];
// Create a rule and link it
$ruleData = [
'RuleCode' => 'DELETED_RULE',
'RuleName' => 'Deleted Test Rule',
'EventCode' => 'ORDER_CREATED',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->assertNotFalse($ruleID);
$this->model->linkTest($ruleID, $testSiteID);
// Verify rule is returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$this->assertNotEmpty($rules);
// Soft delete the rule
$this->model->delete($ruleID);
// Verify deleted rule is NOT returned
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
$this->assertEmpty($rules);
}
/**
* Test getting linked tests for a rule
*/
public function testGetLinkedTests(): void
{
$db = \Config\Database::connect();
// Get two existing tests
$tests = $db->table('testdefsite')
->where('EndDate', null)
->limit(2)
->get()
->getResultArray();
if (count($tests) < 2) {
$this->markTestSkipped('Need at least 2 tests available');
}
$testSiteID1 = (int) $tests[0]['TestSiteID'];
$testSiteID2 = (int) $tests[1]['TestSiteID'];
// Create a rule
$ruleData = [
'RuleCode' => 'LINKED_TESTS',
'RuleName' => 'Linked Tests Rule',
'EventCode' => 'ORDER_CREATED',
'ConditionExpr' => 'true',
'CreateDate' => date('Y-m-d H:i:s'),
];
$ruleID = $this->model->insert($ruleData, true);
$this->model->linkTest($ruleID, $testSiteID1);
$this->model->linkTest($ruleID, $testSiteID2);
// Get linked tests
$linkedTests = $this->model->getLinkedTests($ruleID);
$this->assertCount(2, $linkedTests);
$this->assertContains($testSiteID1, $linkedTests);
$this->assertContains($testSiteID2, $linkedTests);
// Cleanup
$this->model->delete($ruleID);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
class RuleExpressionCompileTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) {
$this->markTestSkipped('Symfony ExpressionLanguage not installed');
}
}
public function testCompileSexCondition(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(sex('F') ? set_result(0.7) : set_result(1))");
$this->assertIsArray($compiled);
$this->assertEquals('order["Sex"] == "F"', $compiled['conditionExpr']);
$this->assertEquals(0.7, $compiled['then']['value']);
$this->assertEquals(1, $compiled['else']['value']);
$this->assertStringContainsString('order["Sex"] == "F"', $compiled['valueExpr']);
}
public function testCompilePriorityCondition(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("if(priority('S') ? set_result('urgent') : set_result('normal'))");
$this->assertIsArray($compiled);
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
$this->assertEquals('urgent', $compiled['then']['value']);
$this->assertEquals('normal', $compiled['else']['value']);
}
public function testCompileInvalidSyntax(): void
{
$svc = new RuleExpressionService();
$this->expectException(\InvalidArgumentException::class);
$svc->compile("invalid syntax here");
}
public function testCompileEmptyReturnsEmpty(): void
{
$svc = new RuleExpressionService();
$compiled = $svc->compile("");
$this->assertIsArray($compiled);
$this->assertEmpty($compiled);
}
}