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
$routes->group('counter', function ($routes) {
$routes->get('/', 'CounterController::index');
@ -354,6 +356,7 @@ $routes->group('api', function ($routes) {
$routes->patch('(:num)', 'Rule\RuleController::update/$1');
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
$routes->post('validate', 'Rule\RuleController::validateExpr');
$routes->post('compile', 'Rule\RuleController::compile');
$routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');

View File

@ -149,4 +149,35 @@ class CalculatorController extends Controller
], 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) {
return $this->db->table('patres pr')
->select('pr.*, tds.TestSiteCode, tds.TestSiteName')
$tests = $this->db->table('patres pr')
->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('discipline d', 'd.DisciplineID = tds.DisciplineID', 'left')
->where('pr.OrderID', $internalOID)
->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()
->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() {

View File

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

View File

@ -26,32 +26,27 @@ class RuleController extends BaseController
{
try {
$eventCode = $this->request->getGet('EventCode');
$active = $this->request->getGet('Active');
$scopeType = $this->request->getGet('ScopeType');
$testSiteID = $this->request->getGet('TestSiteID');
$search = $this->request->getGet('search');
$builder = $this->ruleDefModel->where('EndDate', null);
$builder = $this->ruleDefModel->where('ruledef.EndDate', null);
if ($eventCode !== null && $eventCode !== '') {
$builder->where('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);
$builder->where('ruledef.EventCode', $eventCode);
}
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
->orderBy('Priority', 'ASC')
->orderBy('RuleID', 'ASC')
->orderBy('ruledef.RuleID', 'ASC')
->findAll();
return $this->respond([
@ -88,11 +83,13 @@ class RuleController extends BaseController
$actions = $this->ruleActionModel
->where('RuleID', (int) $id)
->where('EndDate', null)
->orderBy('Seq', 'ASC')
->orderBy('RuleActionID', 'ASC')
->findAll();
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
$rule['actions'] = $actions;
$rule['linkedTests'] = $linkedTests;
return $this->respond([
'status' => 'success',
@ -115,30 +112,30 @@ class RuleController extends BaseController
$validation = service('validation');
$validation->setRules([
'Name' => 'required|max_length[100]',
'RuleCode' => 'required|max_length[50]',
'RuleName' => 'required|max_length[100]',
'EventCode' => 'required|max_length[50]',
'ScopeType' => 'required|in_list[GLOBAL,TESTSITE]',
'TestSiteID' => 'permit_empty|is_natural_no_zero',
'TestSiteIDs' => 'required',
'TestSiteIDs.*' => 'is_natural_no_zero',
'ConditionExpr' => 'permit_empty|max_length[1000]',
'Priority' => 'permit_empty|integer',
'Active' => 'permit_empty|in_list[0,1]',
]);
if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors());
}
if (($input['ScopeType'] ?? 'GLOBAL') === 'TESTSITE') {
if (empty($input['TestSiteID'])) {
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']);
$testSiteIDs = $input['TestSiteIDs'] ?? [];
if (!is_array($testSiteIDs) || empty($testSiteIDs)) {
return $this->failValidationErrors(['TestSiteIDs' => 'At least one TestSiteID is required']);
}
// Validate all TestSiteIDs exist
$testDef = new TestDefSiteModel();
$exists = $testDef->where('EndDate', null)->find((int) $input['TestSiteID']);
foreach ($testSiteIDs as $testSiteID) {
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
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();
@ -146,14 +143,12 @@ class RuleController extends BaseController
try {
$ruleData = [
'Name' => $input['Name'],
'RuleCode' => $input['RuleCode'],
'RuleName' => $input['RuleName'],
'Description' => $input['Description'] ?? null,
'EventCode' => $input['EventCode'],
'ScopeType' => $input['ScopeType'],
'TestSiteID' => $input['TestSiteID'] ?? null,
'ConditionExpr' => $input['ConditionExpr'] ?? null,
'Priority' => $input['Priority'] ?? 100,
'Active' => isset($input['Active']) ? (int) $input['Active'] : 1,
'ConditionExprCompiled' => $input['ConditionExprCompiled'] ?? null,
];
$ruleID = $this->ruleDefModel->insert($ruleData, true);
@ -161,6 +156,12 @@ class RuleController extends BaseController
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'])) {
foreach ($input['actions'] as $action) {
if (!is_array($action)) {
@ -179,7 +180,6 @@ class RuleController extends BaseController
$this->ruleActionModel->insert([
'RuleID' => $ruleID,
'Seq' => $action['Seq'] ?? 1,
'ActionType' => $actionType,
'ActionParams' => is_string($params) ? $params : null,
]);
@ -225,54 +225,76 @@ class RuleController extends BaseController
$validation = service('validation');
$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]',
'ScopeType' => 'permit_empty|in_list[GLOBAL,TESTSITE]',
'TestSiteID' => 'permit_empty|is_natural_no_zero',
'TestSiteIDs' => 'permit_empty',
'TestSiteIDs.*' => 'is_natural_no_zero',
'ConditionExpr' => 'permit_empty|max_length[1000]',
'Priority' => 'permit_empty|integer',
'Active' => 'permit_empty|in_list[0,1]',
]);
if (!$validation->run($input)) {
return $this->failValidationErrors($validation->getErrors());
}
$scopeType = $input['ScopeType'] ?? $existing['ScopeType'] ?? 'GLOBAL';
$testSiteID = array_key_exists('TestSiteID', $input) ? $input['TestSiteID'] : ($existing['TestSiteID'] ?? null);
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;
}
$db = \Config\Database::connect();
$db->transStart();
try {
$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)) {
$updateData[$field] = $input[$field];
}
}
$updateData['TestSiteID'] = $testSiteID;
if (!empty($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([
'status' => 'success',
'message' => 'Rule updated successfully',
'data' => ['RuleID' => (int) $id],
], 200);
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'RuleController::update error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
@ -341,4 +363,40 @@ class RuleController extends BaseController
], 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);
// 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,
@ -503,7 +510,8 @@ class TestsController extends BaseController
if (is_array($rawMembers)) {
foreach ($rawMembers as $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 {
$rawID = is_numeric($member) ? $member : null;
}
@ -519,6 +527,31 @@ class TestsController extends BaseController
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)
{
if ($action === 'update') {
@ -526,19 +559,33 @@ class TestsController extends BaseController
}
$members = $data['members'] ?? ($input['Members'] ?? []);
$memberIDs = [];
if (is_array($members)) {
foreach ($members as $m) {
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m;
if ($memberID) {
// Only accept TestSiteID, not Member (which might be SeqScr)
$memberID = is_array($m) ? ($m['TestSiteID'] ?? null) : $m;
if ($memberID && is_numeric($memberID)) {
$memberIDs[] = (int) $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)
{

View File

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

View File

@ -449,6 +449,55 @@ class OrderSeeder extends Seeder
// Create order status
$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
// ========================================
@ -456,15 +505,16 @@ class OrderSeeder extends Seeder
echo "========================================\n";
echo "ORDER SEEDING COMPLETED SUCCESSFULLY!\n";
echo "========================================\n";
echo "Orders Created: 5\n";
echo "Orders Created: 6\n";
echo " - Order 1: CBC (Complete Blood Count) - COMPLETED\n";
echo " - Order 2: Lipid + Liver Profile - COMPLETED\n";
echo " - Order 3: Renal + Glucose - COMPLETED (URGENT)\n";
echo " - Order 4: Urinalysis - PENDING\n";
echo " - Order 5: CBC + Chemistry - PENDING (Multi-container)\n";
echo " - Order 6: Bilirubin Panel (TBIL, DBIL, IBIL) - COMPLETED\n";
echo "----------------------------------------\n";
echo "Specimens Created: 6\n";
echo " - SST tubes: 4\n";
echo "Specimens Created: 7\n";
echo " - SST tubes: 5\n";
echo " - EDTA tubes: 2\n";
echo " - Urine containers: 1\n";
echo "----------------------------------------\n";

View File

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

View File

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

View File

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

View File

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

View File

@ -4,19 +4,22 @@ namespace App\Models\Rule;
use App\Models\BaseModel;
/**
* RuleDef Model
*
* Rule definitions that can be linked to multiple tests via testrule mapping table.
*/
class RuleDefModel extends BaseModel
{
protected $table = 'ruledef';
protected $primaryKey = 'RuleID';
protected $allowedFields = [
'Name',
'RuleCode',
'RuleName',
'Description',
'EventCode',
'ScopeType',
'TestSiteID',
'ConditionExpr',
'Priority',
'Active',
'ConditionExprCompiled',
'CreateDate',
'StartDate',
'EndDate',
@ -30,32 +33,108 @@ class RuleDefModel extends BaseModel
protected $deletedField = 'EndDate';
/**
* Fetch active rules for an event, optionally scoped.
* Fetch active rules for an event scoped by TestSiteID.
*
* Scope behavior:
* - Always returns GLOBAL rules
* - If $testSiteID provided, also returns TESTSITE rules matching TestSiteID
* Rules are standalone and only apply when explicitly linked to a test
* via the testrule mapping table.
*
* @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
{
$builder = $this->where('EventCode', $eventCode)
->where('EndDate IS NULL')
->where('Active', 1)
->groupStart()
->where('ScopeType', 'GLOBAL');
if ($testSiteID !== null) {
$builder->orGroupStart()
->where('ScopeType', 'TESTSITE')
->where('TestSiteID', $testSiteID)
->groupEnd();
if ($testSiteID === null) {
return [];
}
$builder->groupEnd();
return $builder
->orderBy('Priority', 'ASC')
->orderBy('RuleID', 'ASC')
return $this->select('ruledef.*')
->join('testrule', 'testrule.RuleID = ruledef.RuleID', 'inner')
->where('ruledef.EventCode', $eventCode)
->where('ruledef.EndDate IS NULL')
->where('testrule.TestSiteID', $testSiteID)
->where('testrule.EndDate IS NULL')
->orderBy('ruledef.RuleID', 'ASC')
->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();
}
/**
* 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
*/

View File

@ -38,8 +38,8 @@ class CalculatorService {
*/
public function calculate(string $formula, array $variables = []): ?float {
try {
// Convert placeholders to math-parser compatible format
$expression = $this->prepareExpression($formula, $variables);
$normalizedFormula = $this->normalizeFormulaVariables($formula, $variables);
$expression = $this->prepareExpression($normalizedFormula, $variables);
// Parse the expression
$ast = $this->parser->parse($expression);
@ -115,6 +115,32 @@ class CalculatorService {
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)
*/

View File

@ -59,6 +59,26 @@ class RuleEngineService
}
try {
// Check for compiled expression first
$compiled = null;
if (!empty($rule['ConditionExprCompiled'])) {
$compiled = json_decode($rule['ConditionExprCompiled'], true);
}
if (!empty($compiled) && is_array($compiled)) {
// 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;
@ -67,6 +87,7 @@ class RuleEngineService
foreach ($actionsByRule[$rid] ?? [] as $action) {
$this->executeAction($action, $context);
}
}
} catch (\Throwable $e) {
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
continue;
@ -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
{
$type = strtoupper((string) ($action['ActionType'] ?? ''));

View File

@ -43,4 +43,141 @@ class RuleExpressionService
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
- name: Tests
description: Test definitions and test catalog
- name: Calculations
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders
description: Laboratory order management
- name: Results
@ -54,7 +56,7 @@ tags:
- name: Users
description: User management and administration
- name: Rules
description: Common rule engine (events, conditions, actions)
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
paths:
/api/auth/login:
post:
@ -224,6 +226,44 @@ paths:
responses:
'201':
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:
get:
tags:
@ -1051,13 +1091,6 @@ paths:
VER: Verified
REV: Reviewed
REP: Reported
- name: include
in: query
schema:
type: string
enum:
- details
description: Include specimens and tests in response
responses:
'200':
description: List of orders
@ -1073,7 +1106,7 @@ paths:
data:
type: array
items:
$ref: '#/components/schemas/OrderTest'
$ref: '#/components/schemas/OrderTestList'
post:
tags:
- Orders
@ -3071,32 +3104,16 @@ paths:
schema:
type: string
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
in: query
schema:
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
in: query
schema:
type: string
description: Search by rule name
description: Search by rule code or name
responses:
'200':
description: List of rules
@ -3117,6 +3134,9 @@ paths:
tags:
- Rules
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:
- bearerAuth: []
requestBody:
@ -3126,48 +3146,47 @@ paths:
schema:
type: object
properties:
Name:
RuleCode:
type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description:
type: string
EventCode:
type: string
example: ORDER_CREATED
ScopeType:
type: string
enum:
- GLOBAL
- TESTSITE
TestSiteID:
TestSiteIDs:
type: array
items:
type: integer
nullable: true
description: Array of TestSiteIDs to link this rule to (required)
example:
- 1
- 2
- 3
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum:
- 0
- 1
example: order["Priority"] == "S"
actions:
type: array
items:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
required:
- Name
- RuleCode
- RuleName
- EventCode
- ScopeType
- TestSiteIDs
responses:
'201':
description: Rule created
@ -3175,7 +3194,7 @@ paths:
get:
tags:
- Rules
summary: Get rule (with actions)
summary: Get rule with actions and linked tests
security:
- bearerAuth: []
parameters:
@ -3184,9 +3203,10 @@ paths:
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Rule details
description: Rule details with actions and linked test sites
content:
application/json:
schema:
@ -3197,13 +3217,17 @@ paths:
message:
type: string
data:
$ref: '#/components/schemas/RuleWithActions'
$ref: '#/components/schemas/RuleWithDetails'
'404':
description: Rule not found
patch:
tags:
- Rules
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:
- bearerAuth: []
parameters:
@ -3212,6 +3236,7 @@ paths:
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
@ -3219,30 +3244,22 @@ paths:
schema:
type: object
properties:
Name:
RuleCode:
type: string
RuleName:
type: string
Description:
type: string
EventCode:
type: string
ScopeType:
type: string
enum:
- GLOBAL
- TESTSITE
TestSiteID:
TestSiteIDs:
type: array
items:
type: integer
nullable: true
description: Array of TestSiteIDs to link this rule to
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum:
- 0
- 1
responses:
'200':
description: Rule updated
@ -3260,6 +3277,7 @@ paths:
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Rule deleted
@ -3289,6 +3307,55 @@ paths:
responses:
'200':
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:
get:
tags:
@ -3302,6 +3369,7 @@ paths:
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Actions list
@ -3330,6 +3398,7 @@ paths:
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
@ -3337,10 +3406,9 @@ paths:
schema:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
@ -3363,11 +3431,13 @@ paths:
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
requestBody:
required: true
content:
@ -3375,8 +3445,6 @@ paths:
schema:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
ActionParams:
@ -3398,11 +3466,13 @@ paths:
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
responses:
'200':
description: Action deleted
@ -4433,7 +4503,62 @@ paths:
type: integer
details:
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:
type: array
items:
@ -4451,6 +4576,30 @@ paths:
- TestSiteCode
- TestSiteName
- 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:
'201':
description: Test definition created
@ -4469,6 +4618,19 @@ paths:
properties:
TestSiteId:
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:
tags:
- Tests
@ -4557,7 +4719,62 @@ paths:
type: integer
details:
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:
type: array
items:
@ -4590,6 +4807,19 @@ paths:
properties:
TestSiteId:
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}:
get:
tags:
@ -6262,7 +6492,10 @@ components:
type: object
testdefgrp:
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:
type: object
properties:
@ -6274,10 +6507,9 @@ components:
description: Parent group TestSiteID
Member:
type: integer
description: Member TestSiteID (foreign key to testdefsite)
MemberTestSiteID:
type: integer
description: Member's actual TestSiteID (same as Member, for clarity)
description: |
Member TestSiteID (foreign key to testdefsite).
**Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
TestSiteCode:
type: string
description: Member test code
@ -6623,6 +6855,72 @@ components:
type: string
format: date-time
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:
type: object
properties:
@ -7071,31 +7369,28 @@ components:
properties:
RuleID:
type: integer
Name:
RuleCode:
type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description:
type: string
nullable: true
EventCode:
type: string
ScopeType:
type: string
enum:
- GLOBAL
- TESTSITE
TestSiteID:
type: integer
nullable: true
example: ORDER_CREATED
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum:
- 0
- 1
description: Raw DSL expression (editable)
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
ConditionExprCompiled:
type: string
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:
type: string
format: date-time
@ -7115,8 +7410,6 @@ components:
type: integer
RuleID:
type: integer
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
@ -7124,6 +7417,7 @@ components:
type: string
description: JSON string parameters
nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate:
type: string
format: date-time
@ -7132,7 +7426,7 @@ components:
type: string
format: date-time
nullable: true
RuleWithActions:
RuleWithDetails:
allOf:
- $ref: '#/components/schemas/RuleDef'
- type: object
@ -7141,6 +7435,29 @@ components:
type: array
items:
$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:
type: object
properties:
@ -7263,16 +7580,54 @@ components:
type: string
description: Test name
nullable: true
TestType:
type: string
description: Test type code identifying the test category
enum:
- TEST
- PARAM
- CALC
- GROUP
- TITLE
SID:
type: string
description: Order ID reference
SampleID:
type: string
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:
type: string
description: Test result value
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:
type: string
format: date-time

View File

@ -35,6 +35,8 @@ tags:
description: Specimen and container management
- name: Tests
description: Test definitions and test catalog
- name: Calculations
description: Lightweight calculator endpoint for retrieving computed values by code or name
- name: Orders
description: Laboratory order management
- name: Results
@ -56,7 +58,7 @@ tags:
- name: Users
description: User management and administration
- name: Rules
description: Common rule engine (events, conditions, actions)
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
components:
securitySchemes:
@ -145,6 +147,8 @@ components:
$ref: './components/schemas/tests.yaml#/TestMap'
# Orders schemas
OrderTestList:
$ref: './components/schemas/orders.yaml#/OrderTestList'
OrderTest:
$ref: './components/schemas/orders.yaml#/OrderTest'
OrderItem:
@ -189,8 +193,10 @@ components:
$ref: './components/schemas/rules.yaml#/RuleDef'
RuleAction:
$ref: './components/schemas/rules.yaml#/RuleAction'
RuleWithActions:
$ref: './components/schemas/rules.yaml#/RuleWithActions'
RuleWithDetails:
$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
# 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:
type: object
properties:
@ -132,16 +190,49 @@ OrderTestItem:
type: string
description: Test name
nullable: true
TestType:
type: string
description: Test type code identifying the test category
enum: [TEST, PARAM, CALC, GROUP, TITLE]
SID:
type: string
description: Order ID reference
SampleID:
type: string
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:
type: string
description: Test result value
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:
type: string
format: date-time

View File

@ -3,27 +3,28 @@ RuleDef:
properties:
RuleID:
type: integer
Name:
RuleCode:
type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description:
type: string
nullable: true
EventCode:
type: string
ScopeType:
type: string
enum: [GLOBAL, TESTSITE]
TestSiteID:
type: integer
nullable: true
example: ORDER_CREATED
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum: [0, 1]
description: Raw DSL expression (editable)
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
ConditionExprCompiled:
type: string
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:
type: string
format: date-time
@ -44,8 +45,6 @@ RuleAction:
type: integer
RuleID:
type: integer
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
@ -53,6 +52,7 @@ RuleAction:
type: string
description: JSON string parameters
nullable: true
example: '{"testSiteID": 1, "value": "Normal"}'
CreateDate:
type: string
format: date-time
@ -62,7 +62,7 @@ RuleAction:
format: date-time
nullable: true
RuleWithActions:
RuleWithDetails:
allOf:
- $ref: './rules.yaml#/RuleDef'
- type: object
@ -71,3 +71,27 @@ RuleWithActions:
type: array
items:
$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
testdefgrp:
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:
type: object
properties:
@ -153,10 +156,9 @@ TestDefinition:
description: Parent group TestSiteID
Member:
type: integer
description: Member TestSiteID (foreign key to testdefsite)
MemberTestSiteID:
type: integer
description: Member's actual TestSiteID (same as Member, for clarity)
description: |
Member TestSiteID (foreign key to testdefsite).
**Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
TestSiteCode:
type: string
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
REV: Reviewed
REP: Reported
- name: include
in: query
schema:
type: string
enum: [details]
description: Include specimens and tests in response
responses:
'200':
description: List of orders
@ -51,7 +45,7 @@
data:
type: array
items:
$ref: '../components/schemas/orders.yaml#/OrderTest'
$ref: '../components/schemas/orders.yaml#/OrderTestList'
post:
tags: [Orders]

View File

@ -10,28 +10,16 @@
schema:
type: string
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
in: query
schema:
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
in: query
schema:
type: string
description: Search by rule name
description: Search by rule code or name
responses:
'200':
description: List of rules
@ -52,6 +40,9 @@
post:
tags: [Rules]
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:
- bearerAuth: []
requestBody:
@ -61,41 +52,40 @@
schema:
type: object
properties:
Name:
RuleCode:
type: string
example: AUTO_SET_RESULT
RuleName:
type: string
example: Automatically Set Result
Description:
type: string
EventCode:
type: string
example: ORDER_CREATED
ScopeType:
type: string
enum: [GLOBAL, TESTSITE]
TestSiteID:
TestSiteIDs:
type: array
items:
type: integer
nullable: true
description: Array of TestSiteIDs to link this rule to (required)
example: [1, 2, 3]
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum: [0, 1]
example: 'order["Priority"] == "S"'
actions:
type: array
items:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
- type: object
required: [Name, EventCode, ScopeType]
required: [RuleCode, RuleName, EventCode, TestSiteIDs]
responses:
'201':
description: Rule created
@ -103,7 +93,7 @@
/api/rules/{id}:
get:
tags: [Rules]
summary: Get rule (with actions)
summary: Get rule with actions and linked tests
security:
- bearerAuth: []
parameters:
@ -112,9 +102,10 @@
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Rule details
description: Rule details with actions and linked test sites
content:
application/json:
schema:
@ -125,13 +116,17 @@
message:
type: string
data:
$ref: '../components/schemas/rules.yaml#/RuleWithActions'
$ref: '../components/schemas/rules.yaml#/RuleWithDetails'
'404':
description: Rule not found
patch:
tags: [Rules]
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:
- bearerAuth: []
parameters:
@ -140,6 +135,7 @@
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
@ -147,14 +143,16 @@
schema:
type: object
properties:
Name: { type: string }
RuleCode: { type: string }
RuleName: { type: string }
Description: { type: string }
EventCode: { type: string }
ScopeType: { type: string, enum: [GLOBAL, TESTSITE] }
TestSiteID: { type: integer, nullable: true }
TestSiteIDs:
type: array
items:
type: integer
description: Array of TestSiteIDs to link this rule to
ConditionExpr: { type: string, nullable: true }
Priority: { type: integer }
Active: { type: integer, enum: [0, 1] }
responses:
'200':
description: Rule updated
@ -172,6 +170,7 @@
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Rule deleted
@ -201,6 +200,54 @@
'200':
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:
get:
tags: [Rules]
@ -213,6 +260,7 @@
required: true
schema:
type: integer
description: RuleID
responses:
'200':
description: Actions list
@ -241,6 +289,7 @@
required: true
schema:
type: integer
description: RuleID
requestBody:
required: true
content:
@ -248,10 +297,9 @@
schema:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
example: SET_RESULT
ActionParams:
oneOf:
- type: string
@ -273,11 +321,13 @@
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
requestBody:
required: true
content:
@ -285,7 +335,6 @@
schema:
type: object
properties:
Seq: { type: integer }
ActionType: { type: string }
ActionParams:
oneOf:
@ -306,11 +355,13 @@
required: true
schema:
type: integer
description: RuleID
- name: actionId
in: path
required: true
schema:
type: integer
description: RuleActionID
responses:
'200':
description: Action deleted

View File

@ -141,7 +141,52 @@
type: integer
details:
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:
type: array
items:
@ -159,6 +204,30 @@
- TestSiteCode
- TestSiteName
- 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:
'201':
description: Test definition created
@ -177,6 +246,19 @@
properties:
TestSiteId:
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:
tags: [Tests]
@ -250,7 +332,52 @@
type: integer
details:
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:
type: array
items:
@ -283,6 +410,19 @@
properties:
TestSiteId:
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}:
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()
{
$faker = Factory::create('id_ID');
// 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));
$internalPID = $this->createOrderTestPatient();
// Get available tests from testdefsite
$testsResult = $this->call('get', 'api/test');
@ -127,9 +68,11 @@ class OrderCreateTest extends CIUnitTestCase
$result->assertStatus(400);
$body = json_decode($result->getBody(), true);
$body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body);
$this->assertArrayHasKey('errors', $body);
$messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
}
public function testCreateOrderFailsWithInvalidPatient()
@ -145,34 +88,16 @@ class OrderCreateTest extends CIUnitTestCase
$result->assertStatus(400);
$body = json_decode($result->getBody(), true);
$body = json_decode(strip_tags($result->getBody()), true);
$this->assertIsArray($body);
$this->assertArrayHasKey('errors', $body);
$messages = $body['messages'] ?? $body['errors'] ?? [];
$this->assertIsArray($messages);
$this->assertArrayHasKey('InternalPID', $messages);
}
public function testCreateOrderWithMultipleTests()
{
$faker = Factory::create('id_ID');
// 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');
$internalPID = $this->createOrderTestPatient();
// Get available tests
$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(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);
}
}