From c01786bb93b166deb31a8a876f88c720cce82e11 Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Thu, 12 Mar 2026 16:55:03 +0700 Subject: [PATCH] feat: add calc endpoint and rule engine compilation --- app/Config/Routes.php | 3 + app/Controllers/CalculatorController.php | 31 + app/Controllers/OrderTestController.php | 30 +- app/Controllers/Rule/RuleActionController.php | 6 +- app/Controllers/Rule/RuleController.php | 176 ++++-- app/Controllers/Test/TestsController.php | 61 +- ... => 2026-03-12-000040_CreateTestRules.php} | 45 +- app/Database/Seeds/OrderSeeder.php | 56 +- app/Libraries/Data/enable_disable.json | 4 +- app/Libraries/Data/sex.json | 6 +- app/Libraries/Data/ws_type.json | 4 +- app/Models/Rule/RuleActionModel.php | 13 +- app/Models/Rule/RuleDefModel.php | 129 ++++- app/Models/Rule/TestRuleModel.php | 71 +++ app/Models/Test/TestDefCalModel.php | 28 + app/Services/CalculatorService.php | 30 +- app/Services/RuleEngineService.php | 96 ++- app/Services/RuleExpressionService.php | 137 +++++ public/api-docs.bundled.yaml | 547 +++++++++++++++--- public/api-docs.yaml | 12 +- public/components/schemas/orders.yaml | 91 +++ public/components/schemas/rules.yaml | 54 +- public/components/schemas/tests.yaml | 12 +- public/paths/calc.yaml | 37 ++ public/paths/orders.yaml | 8 +- public/paths/rules.yaml | 131 +++-- public/paths/tests.yaml | 144 ++++- .../Calculator/CalculatorEndpointTest.php | 148 +++++ tests/feature/Orders/OrderCreateTest.php | 245 +++++--- tests/unit/Rule/RuleDefModelTest.php | 271 +++++++++ .../unit/Rules/RuleExpressionCompileTest.php | 60 ++ 31 files changed, 2301 insertions(+), 385 deletions(-) rename app/Database/Migrations/{2026-03-11-000030_CreateRules.php => 2026-03-12-000040_CreateTestRules.php} (51%) create mode 100644 app/Models/Rule/TestRuleModel.php create mode 100644 public/paths/calc.yaml create mode 100644 tests/feature/Calculator/CalculatorEndpointTest.php create mode 100644 tests/unit/Rule/RuleDefModelTest.php create mode 100644 tests/unit/Rules/RuleExpressionCompileTest.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 72ca74a..04f6dc2 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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'); diff --git a/app/Controllers/CalculatorController.php b/app/Controllers/CalculatorController.php index 57c20f1..80b4e84 100644 --- a/app/Controllers/CalculatorController.php +++ b/app/Controllers/CalculatorController.php @@ -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()); + } + } } diff --git a/app/Controllers/OrderTestController.php b/app/Controllers/OrderTestController.php index f82f770..5a04553 100644 --- a/app/Controllers/OrderTestController.php +++ b/app/Controllers/OrderTestController.php @@ -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() { diff --git a/app/Controllers/Rule/RuleActionController.php b/app/Controllers/Rule/RuleActionController.php index 2e2c2ae..16aa346 100644 --- a/app/Controllers/Rule/RuleActionController.php +++ b/app/Controllers/Rule/RuleActionController.php @@ -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]; } diff --git a/app/Controllers/Rule/RuleController.php b/app/Controllers/Rule/RuleController.php index 0ee3abb..6da06cb 100644 --- a/app/Controllers/Rule/RuleController.php +++ b/app/Controllers/Rule/RuleController.php @@ -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']); - } - $testDef = new TestDefSiteModel(); - $exists = $testDef->where('EndDate', null)->find((int) $input['TestSiteID']); + $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(); + 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); + } + } } diff --git a/app/Controllers/Test/TestsController.php b/app/Controllers/Test/TestsController.php index fff2ffc..46d60e1 100644 --- a/app/Controllers/Test/TestsController.php +++ b/app/Controllers/Test/TestsController.php @@ -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,18 +559,32 @@ 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) { - $this->modelGrp->insert([ - 'TestSiteID' => $testSiteID, - 'Member' => $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) diff --git a/app/Database/Migrations/2026-03-11-000030_CreateRules.php b/app/Database/Migrations/2026-03-12-000040_CreateTestRules.php similarity index 51% rename from app/Database/Migrations/2026-03-11-000030_CreateRules.php rename to app/Database/Migrations/2026-03-12-000040_CreateTestRules.php index 455ffa1..9e936d0 100644 --- a/app/Database/Migrations/2026-03-11-000030_CreateRules.php +++ b/app/Database/Migrations/2026-03-12-000040_CreateTestRules.php @@ -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); } } diff --git a/app/Database/Seeds/OrderSeeder.php b/app/Database/Seeds/OrderSeeder.php index c62cfb9..bec8bd6 100644 --- a/app/Database/Seeds/OrderSeeder.php +++ b/app/Database/Seeds/OrderSeeder.php @@ -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"; diff --git a/app/Libraries/Data/enable_disable.json b/app/Libraries/Data/enable_disable.json index 51734f3..9cb9705 100644 --- a/app/Libraries/Data/enable_disable.json +++ b/app/Libraries/Data/enable_disable.json @@ -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"} ] } diff --git a/app/Libraries/Data/sex.json b/app/Libraries/Data/sex.json index 70b4f0e..6e2aded 100644 --- a/app/Libraries/Data/sex.json +++ b/app/Libraries/Data/sex.json @@ -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"} ] } diff --git a/app/Libraries/Data/ws_type.json b/app/Libraries/Data/ws_type.json index 092ba6c..be5cea5 100644 --- a/app/Libraries/Data/ws_type.json +++ b/app/Libraries/Data/ws_type.json @@ -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"} ] } diff --git a/app/Models/Rule/RuleActionModel.php b/app/Models/Rule/RuleActionModel.php index 7e8be09..737adfb 100644 --- a/app/Models/Rule/RuleActionModel.php +++ b/app/Models/Rule/RuleActionModel.php @@ -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(); } diff --git a/app/Models/Rule/RuleDefModel.php b/app/Models/Rule/RuleDefModel.php index 3a6e996..10b182b 100644 --- a/app/Models/Rule/RuleDefModel.php +++ b/app/Models/Rule/RuleDefModel.php @@ -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')]); + } } diff --git a/app/Models/Rule/TestRuleModel.php b/app/Models/Rule/TestRuleModel.php new file mode 100644 index 0000000..7f66057 --- /dev/null +++ b/app/Models/Rule/TestRuleModel.php @@ -0,0 +1,71 @@ +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; + } +} diff --git a/app/Models/Test/TestDefCalModel.php b/app/Models/Test/TestDefCalModel.php index 04b9f57..c81df6e 100644 --- a/app/Models/Test/TestDefCalModel.php +++ b/app/Models/Test/TestDefCalModel.php @@ -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 */ diff --git a/app/Services/CalculatorService.php b/app/Services/CalculatorService.php index e6e88fc..f3be922 100644 --- a/app/Services/CalculatorService.php +++ b/app/Services/CalculatorService.php @@ -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) */ diff --git a/app/Services/RuleEngineService.php b/app/Services/RuleEngineService.php index 8b27954..cb7b53b 100644 --- a/app/Services/RuleEngineService.php +++ b/app/Services/RuleEngineService.php @@ -59,13 +59,34 @@ class RuleEngineService } try { - $matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context); - if (!$matches) { - continue; + // Check for compiled expression first + $compiled = null; + if (!empty($rule['ConditionExprCompiled'])) { + $compiled = json_decode($rule['ConditionExprCompiled'], true); } - foreach ($actionsByRule[$rid] ?? [] as $action) { - $this->executeAction($action, $context); + 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; + } + + foreach ($actionsByRule[$rid] ?? [] as $action) { + $this->executeAction($action, $context); + } } } catch (\Throwable $e) { log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage()); @@ -74,6 +95,71 @@ class RuleEngineService } } + /** + * Execute SET_RESULT action using compiled valueExpr. + * Automatically creates the test result if it doesn't exist. + */ + protected function executeCompiledSetResult(int $ruleID, string $valueExpr, array $context): void + { + $order = $context['order'] ?? null; + if (!is_array($order) || empty($order['InternalOID'])) { + throw new \Exception('SET_RESULT requires context.order.InternalOID'); + } + + $internalOID = (int) $order['InternalOID']; + $testSiteID = $context['testSiteID'] ?? null; + + if ($testSiteID === null && isset($order['TestSiteID'])) { + $testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null; + } + + if ($testSiteID === null) { + // Try to get testSiteID from context tests + $tests = $context['tests'] ?? []; + if (!empty($tests) && is_array($tests)) { + $testSiteID = (int) ($tests[0]['TestSiteID'] ?? null); + } + } + + if ($testSiteID === null) { + throw new \Exception('SET_RESULT requires testSiteID'); + } + + // Evaluate the value expression + $value = $this->expr->evaluate($valueExpr, $context); + + $db = \Config\Database::connect(); + + // Check if patres row exists + $patres = $db->table('patres') + ->where('OrderID', $internalOID) + ->where('TestSiteID', $testSiteID) + ->where('DelDate', null) + ->get() + ->getRowArray(); + + if ($patres) { + // Update existing result + $ok = $db->table('patres') + ->where('OrderID', $internalOID) + ->where('TestSiteID', $testSiteID) + ->where('DelDate', null) + ->update(['Result' => $value]); + } else { + // Insert new result row + $ok = $db->table('patres')->insert([ + 'OrderID' => $internalOID, + 'TestSiteID' => $testSiteID, + 'Result' => $value, + 'CreateDate' => date('Y-m-d H:i:s'), + ]); + } + + if ($ok === false) { + throw new \Exception('SET_RESULT update/insert failed'); + } + } + protected function executeAction(array $action, array $context): void { $type = strtoupper((string) ($action['ActionType'] ?? '')); diff --git a/app/Services/RuleExpressionService.php b/app/Services/RuleExpressionService.php index d4c989b..042875a 100644 --- a/app/Services/RuleExpressionService.php +++ b/app/Services/RuleExpressionService.php @@ -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); + } } diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 8026e05..3c9a43f 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -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: - type: integer - nullable: true + TestSiteIDs: + type: array + items: + type: integer + 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: - 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 @@ -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 diff --git a/public/api-docs.yaml b/public/api-docs.yaml index 1361325..849676b 100644 --- a/public/api-docs.yaml +++ b/public/api-docs.yaml @@ -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 diff --git a/public/components/schemas/orders.yaml b/public/components/schemas/orders.yaml index ece9d8e..1a3a99e 100644 --- a/public/components/schemas/orders.yaml +++ b/public/components/schemas/orders.yaml @@ -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 diff --git a/public/components/schemas/rules.yaml b/public/components/schemas/rules.yaml index 0574d23..543a699 100644 --- a/public/components/schemas/rules.yaml +++ b/public/components/schemas/rules.yaml @@ -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 diff --git a/public/components/schemas/tests.yaml b/public/components/schemas/tests.yaml index 285fbcf..ecc3aac 100644 --- a/public/components/schemas/tests.yaml +++ b/public/components/schemas/tests.yaml @@ -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 diff --git a/public/paths/calc.yaml b/public/paths/calc.yaml new file mode 100644 index 0000000..bca9a95 --- /dev/null +++ b/public/paths/calc.yaml @@ -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: {} diff --git a/public/paths/orders.yaml b/public/paths/orders.yaml index 444caf0..00fb9b8 100644 --- a/public/paths/orders.yaml +++ b/public/paths/orders.yaml @@ -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] diff --git a/public/paths/rules.yaml b/public/paths/rules.yaml index 014a9d5..e31bcb0 100644 --- a/public/paths/rules.yaml +++ b/public/paths/rules.yaml @@ -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: - type: integer - nullable: true + TestSiteIDs: + type: array + items: + type: integer + 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 diff --git a/public/paths/tests.yaml b/public/paths/tests.yaml index 2735561..f61b539 100644 --- a/public/paths/tests.yaml +++ b/public/paths/tests.yaml @@ -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: diff --git a/tests/feature/Calculator/CalculatorEndpointTest.php b/tests/feature/Calculator/CalculatorEndpointTest.php new file mode 100644 index 0000000..3238ea9 --- /dev/null +++ b/tests/feature/Calculator/CalculatorEndpointTest.php @@ -0,0 +1,148 @@ +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; + } +} diff --git a/tests/feature/Orders/OrderCreateTest.php b/tests/feature/Orders/OrderCreateTest.php index d9b0167..2d64b23 100644 --- a/tests/feature/Orders/OrderCreateTest.php +++ b/tests/feature/Orders/OrderCreateTest.php @@ -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; + } } diff --git a/tests/unit/Rule/RuleDefModelTest.php b/tests/unit/Rule/RuleDefModelTest.php new file mode 100644 index 0000000..c7dcc81 --- /dev/null +++ b/tests/unit/Rule/RuleDefModelTest.php @@ -0,0 +1,271 @@ +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); + } +} diff --git a/tests/unit/Rules/RuleExpressionCompileTest.php b/tests/unit/Rules/RuleExpressionCompileTest.php new file mode 100644 index 0000000..c4068c3 --- /dev/null +++ b/tests/unit/Rules/RuleExpressionCompileTest.php @@ -0,0 +1,60 @@ +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); + } +}