feat: add calc endpoint and rule engine compilation
This commit is contained in:
parent
88be3f3809
commit
c01786bb93
@ -152,6 +152,8 @@ $routes->group('api', function ($routes) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$routes->post('calc/(:any)', 'CalculatorController::calculateByCodeOrName/$1');
|
||||||
|
|
||||||
// Counter
|
// Counter
|
||||||
$routes->group('counter', function ($routes) {
|
$routes->group('counter', function ($routes) {
|
||||||
$routes->get('/', 'CounterController::index');
|
$routes->get('/', 'CounterController::index');
|
||||||
@ -354,6 +356,7 @@ $routes->group('api', function ($routes) {
|
|||||||
$routes->patch('(:num)', 'Rule\RuleController::update/$1');
|
$routes->patch('(:num)', 'Rule\RuleController::update/$1');
|
||||||
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
|
$routes->delete('(:num)', 'Rule\RuleController::delete/$1');
|
||||||
$routes->post('validate', 'Rule\RuleController::validateExpr');
|
$routes->post('validate', 'Rule\RuleController::validateExpr');
|
||||||
|
$routes->post('compile', 'Rule\RuleController::compile');
|
||||||
|
|
||||||
$routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
|
$routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1');
|
||||||
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');
|
$routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1');
|
||||||
|
|||||||
@ -149,4 +149,35 @@ class CalculatorController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST api/calc/{codeOrName}
|
||||||
|
* Evaluate a configured calculation by its code or name and return only the result map.
|
||||||
|
*/
|
||||||
|
public function calculateByCodeOrName($codeOrName): ResponseInterface
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$calcDef = $this->calcModel->findActiveByCodeOrName($codeOrName);
|
||||||
|
|
||||||
|
if (!$calcDef || empty($calcDef['FormulaCode'])) {
|
||||||
|
return $this->response->setJSON(new \stdClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = $this->request->getJSON(true);
|
||||||
|
$variables = is_array($input) ? $input : [];
|
||||||
|
|
||||||
|
$result = $this->calculator->calculate($calcDef['FormulaCode'], $variables);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
return $this->response->setJSON(new \stdClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseKey = $calcDef['TestSiteCode'] ?? strtoupper($codeOrName);
|
||||||
|
|
||||||
|
return $this->response->setJSON([ $responseKey => $result ]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
log_message('error', "Calc lookup failed for {$codeOrName}: " . $e->getMessage());
|
||||||
|
return $this->response->setJSON(new \stdClass());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,13 +119,39 @@ class OrderTestController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function getOrderTests($internalOID) {
|
private function getOrderTests($internalOID) {
|
||||||
return $this->db->table('patres pr')
|
$tests = $this->db->table('patres pr')
|
||||||
->select('pr.*, tds.TestSiteCode, tds.TestSiteName')
|
->select('pr.*, tds.TestSiteCode, tds.TestSiteName, tds.TestType, tds.SeqScr AS TestSeqScr, tds.SeqRpt AS TestSeqRpt, tds.DisciplineID, d.DisciplineCode, d.DisciplineName, d.SeqScr AS DisciplineSeqScr, d.SeqRpt AS DisciplineSeqRpt')
|
||||||
->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left')
|
->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left')
|
||||||
|
->join('discipline d', 'd.DisciplineID = tds.DisciplineID', 'left')
|
||||||
->where('pr.OrderID', $internalOID)
|
->where('pr.OrderID', $internalOID)
|
||||||
->where('pr.DelDate IS NULL')
|
->where('pr.DelDate IS NULL')
|
||||||
|
->orderBy('COALESCE(d.SeqScr, 999999) ASC')
|
||||||
|
->orderBy('COALESCE(d.SeqRpt, 999999) ASC')
|
||||||
|
->orderBy('COALESCE(tds.SeqScr, 999999) ASC')
|
||||||
|
->orderBy('COALESCE(tds.SeqRpt, 999999) ASC')
|
||||||
|
->orderBy('pr.ResultID ASC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($tests as &$test) {
|
||||||
|
$discipline = [
|
||||||
|
'DisciplineID' => $test['DisciplineID'] ?? null,
|
||||||
|
'DisciplineCode' => $test['DisciplineCode'] ?? null,
|
||||||
|
'DisciplineName' => $test['DisciplineName'] ?? null,
|
||||||
|
'SeqScr' => $test['DisciplineSeqScr'] ?? null,
|
||||||
|
'SeqRpt' => $test['DisciplineSeqRpt'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$test['Discipline'] = $discipline;
|
||||||
|
$test['SeqScr'] = $test['TestSeqScr'] ?? null;
|
||||||
|
$test['SeqRpt'] = $test['TestSeqRpt'] ?? null;
|
||||||
|
$test['DisciplineID'] = $discipline['DisciplineID'];
|
||||||
|
|
||||||
|
unset($test['DisciplineCode'], $test['DisciplineName'], $test['DisciplineSeqScr'], $test['DisciplineSeqRpt'], $test['TestSeqScr'], $test['TestSeqRpt']);
|
||||||
|
}
|
||||||
|
unset($test);
|
||||||
|
|
||||||
|
return $tests;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create() {
|
||||||
|
|||||||
@ -40,7 +40,6 @@ class RuleActionController extends BaseController
|
|||||||
$rows = $this->ruleActionModel
|
$rows = $this->ruleActionModel
|
||||||
->where('RuleID', (int) $ruleID)
|
->where('RuleID', (int) $ruleID)
|
||||||
->where('EndDate', null)
|
->where('EndDate', null)
|
||||||
->orderBy('Seq', 'ASC')
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
->orderBy('RuleActionID', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
@ -77,7 +76,6 @@ class RuleActionController extends BaseController
|
|||||||
|
|
||||||
$validation = service('validation');
|
$validation = service('validation');
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'Seq' => 'permit_empty|integer',
|
|
||||||
'ActionType' => 'required|max_length[50]',
|
'ActionType' => 'required|max_length[50]',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -117,7 +115,6 @@ class RuleActionController extends BaseController
|
|||||||
|
|
||||||
$id = $this->ruleActionModel->insert([
|
$id = $this->ruleActionModel->insert([
|
||||||
'RuleID' => (int) $ruleID,
|
'RuleID' => (int) $ruleID,
|
||||||
'Seq' => $input['Seq'] ?? 1,
|
|
||||||
'ActionType' => $input['ActionType'],
|
'ActionType' => $input['ActionType'],
|
||||||
'ActionParams' => is_string($params) ? $params : null,
|
'ActionParams' => is_string($params) ? $params : null,
|
||||||
], true);
|
], true);
|
||||||
@ -167,7 +164,6 @@ class RuleActionController extends BaseController
|
|||||||
|
|
||||||
$validation = service('validation');
|
$validation = service('validation');
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'Seq' => 'permit_empty|integer',
|
|
||||||
'ActionType' => 'permit_empty|max_length[50]',
|
'ActionType' => 'permit_empty|max_length[50]',
|
||||||
]);
|
]);
|
||||||
if (!$validation->run($input)) {
|
if (!$validation->run($input)) {
|
||||||
@ -176,7 +172,7 @@ class RuleActionController extends BaseController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$updateData = [];
|
$updateData = [];
|
||||||
foreach (['Seq', 'ActionType', 'ActionParams'] as $field) {
|
foreach (['ActionType', 'ActionParams'] as $field) {
|
||||||
if (array_key_exists($field, $input)) {
|
if (array_key_exists($field, $input)) {
|
||||||
$updateData[$field] = $input[$field];
|
$updateData[$field] = $input[$field];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,32 +26,27 @@ class RuleController extends BaseController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$eventCode = $this->request->getGet('EventCode');
|
$eventCode = $this->request->getGet('EventCode');
|
||||||
$active = $this->request->getGet('Active');
|
|
||||||
$scopeType = $this->request->getGet('ScopeType');
|
|
||||||
$testSiteID = $this->request->getGet('TestSiteID');
|
$testSiteID = $this->request->getGet('TestSiteID');
|
||||||
$search = $this->request->getGet('search');
|
$search = $this->request->getGet('search');
|
||||||
|
|
||||||
$builder = $this->ruleDefModel->where('EndDate', null);
|
$builder = $this->ruleDefModel->where('ruledef.EndDate', null);
|
||||||
|
|
||||||
if ($eventCode !== null && $eventCode !== '') {
|
if ($eventCode !== null && $eventCode !== '') {
|
||||||
$builder->where('EventCode', $eventCode);
|
$builder->where('ruledef.EventCode', $eventCode);
|
||||||
}
|
|
||||||
if ($active !== null && $active !== '') {
|
|
||||||
$builder->where('Active', (int) $active);
|
|
||||||
}
|
|
||||||
if ($scopeType !== null && $scopeType !== '') {
|
|
||||||
$builder->where('ScopeType', $scopeType);
|
|
||||||
}
|
|
||||||
if ($testSiteID !== null && $testSiteID !== '' && is_numeric($testSiteID)) {
|
|
||||||
$builder->where('TestSiteID', (int) $testSiteID);
|
|
||||||
}
|
}
|
||||||
if ($search !== null && $search !== '') {
|
if ($search !== null && $search !== '') {
|
||||||
$builder->like('Name', $search);
|
$builder->like('ruledef.RuleName', $search);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by TestSiteID - join with mapping table
|
||||||
|
if ($testSiteID !== null && $testSiteID !== '' && is_numeric($testSiteID)) {
|
||||||
|
$builder->join('testrule', 'testrule.RuleID = ruledef.RuleID', 'inner');
|
||||||
|
$builder->where('testrule.TestSiteID', (int) $testSiteID);
|
||||||
|
$builder->where('testrule.EndDate IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $builder
|
$rows = $builder
|
||||||
->orderBy('Priority', 'ASC')
|
->orderBy('ruledef.RuleID', 'ASC')
|
||||||
->orderBy('RuleID', 'ASC')
|
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
@ -88,11 +83,13 @@ class RuleController extends BaseController
|
|||||||
$actions = $this->ruleActionModel
|
$actions = $this->ruleActionModel
|
||||||
->where('RuleID', (int) $id)
|
->where('RuleID', (int) $id)
|
||||||
->where('EndDate', null)
|
->where('EndDate', null)
|
||||||
->orderBy('Seq', 'ASC')
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
->orderBy('RuleActionID', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
|
$linkedTests = $this->ruleDefModel->getLinkedTests((int) $id);
|
||||||
|
|
||||||
$rule['actions'] = $actions;
|
$rule['actions'] = $actions;
|
||||||
|
$rule['linkedTests'] = $linkedTests;
|
||||||
|
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
@ -115,30 +112,30 @@ class RuleController extends BaseController
|
|||||||
|
|
||||||
$validation = service('validation');
|
$validation = service('validation');
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'Name' => 'required|max_length[100]',
|
'RuleCode' => 'required|max_length[50]',
|
||||||
|
'RuleName' => 'required|max_length[100]',
|
||||||
'EventCode' => 'required|max_length[50]',
|
'EventCode' => 'required|max_length[50]',
|
||||||
'ScopeType' => 'required|in_list[GLOBAL,TESTSITE]',
|
'TestSiteIDs' => 'required',
|
||||||
'TestSiteID' => 'permit_empty|is_natural_no_zero',
|
'TestSiteIDs.*' => 'is_natural_no_zero',
|
||||||
'ConditionExpr' => 'permit_empty|max_length[1000]',
|
'ConditionExpr' => 'permit_empty|max_length[1000]',
|
||||||
'Priority' => 'permit_empty|integer',
|
|
||||||
'Active' => 'permit_empty|in_list[0,1]',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$validation->run($input)) {
|
if (!$validation->run($input)) {
|
||||||
return $this->failValidationErrors($validation->getErrors());
|
return $this->failValidationErrors($validation->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($input['ScopeType'] ?? 'GLOBAL') === 'TESTSITE') {
|
$testSiteIDs = $input['TestSiteIDs'] ?? [];
|
||||||
if (empty($input['TestSiteID'])) {
|
if (!is_array($testSiteIDs) || empty($testSiteIDs)) {
|
||||||
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']);
|
return $this->failValidationErrors(['TestSiteIDs' => 'At least one TestSiteID is required']);
|
||||||
}
|
}
|
||||||
$testDef = new TestDefSiteModel();
|
|
||||||
$exists = $testDef->where('EndDate', null)->find((int) $input['TestSiteID']);
|
// Validate all TestSiteIDs exist
|
||||||
|
$testDef = new TestDefSiteModel();
|
||||||
|
foreach ($testSiteIDs as $testSiteID) {
|
||||||
|
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
|
||||||
if (!$exists) {
|
if (!$exists) {
|
||||||
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']);
|
return $this->failValidationErrors(['TestSiteIDs' => "TestSiteID {$testSiteID} not found"]);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$input['TestSiteID'] = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
@ -146,14 +143,12 @@ class RuleController extends BaseController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$ruleData = [
|
$ruleData = [
|
||||||
'Name' => $input['Name'],
|
'RuleCode' => $input['RuleCode'],
|
||||||
|
'RuleName' => $input['RuleName'],
|
||||||
'Description' => $input['Description'] ?? null,
|
'Description' => $input['Description'] ?? null,
|
||||||
'EventCode' => $input['EventCode'],
|
'EventCode' => $input['EventCode'],
|
||||||
'ScopeType' => $input['ScopeType'],
|
|
||||||
'TestSiteID' => $input['TestSiteID'] ?? null,
|
|
||||||
'ConditionExpr' => $input['ConditionExpr'] ?? null,
|
'ConditionExpr' => $input['ConditionExpr'] ?? null,
|
||||||
'Priority' => $input['Priority'] ?? 100,
|
'ConditionExprCompiled' => $input['ConditionExprCompiled'] ?? null,
|
||||||
'Active' => isset($input['Active']) ? (int) $input['Active'] : 1,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$ruleID = $this->ruleDefModel->insert($ruleData, true);
|
$ruleID = $this->ruleDefModel->insert($ruleData, true);
|
||||||
@ -161,6 +156,12 @@ class RuleController extends BaseController
|
|||||||
throw new \Exception('Failed to create rule');
|
throw new \Exception('Failed to create rule');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link rule to test sites
|
||||||
|
foreach ($testSiteIDs as $testSiteID) {
|
||||||
|
$this->ruleDefModel->linkTest($ruleID, (int) $testSiteID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create actions if provided
|
||||||
if (isset($input['actions']) && is_array($input['actions'])) {
|
if (isset($input['actions']) && is_array($input['actions'])) {
|
||||||
foreach ($input['actions'] as $action) {
|
foreach ($input['actions'] as $action) {
|
||||||
if (!is_array($action)) {
|
if (!is_array($action)) {
|
||||||
@ -179,7 +180,6 @@ class RuleController extends BaseController
|
|||||||
|
|
||||||
$this->ruleActionModel->insert([
|
$this->ruleActionModel->insert([
|
||||||
'RuleID' => $ruleID,
|
'RuleID' => $ruleID,
|
||||||
'Seq' => $action['Seq'] ?? 1,
|
|
||||||
'ActionType' => $actionType,
|
'ActionType' => $actionType,
|
||||||
'ActionParams' => is_string($params) ? $params : null,
|
'ActionParams' => is_string($params) ? $params : null,
|
||||||
]);
|
]);
|
||||||
@ -225,54 +225,76 @@ class RuleController extends BaseController
|
|||||||
|
|
||||||
$validation = service('validation');
|
$validation = service('validation');
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'Name' => 'permit_empty|max_length[100]',
|
'RuleCode' => 'permit_empty|max_length[50]',
|
||||||
|
'RuleName' => 'permit_empty|max_length[100]',
|
||||||
'EventCode' => 'permit_empty|max_length[50]',
|
'EventCode' => 'permit_empty|max_length[50]',
|
||||||
'ScopeType' => 'permit_empty|in_list[GLOBAL,TESTSITE]',
|
'TestSiteIDs' => 'permit_empty',
|
||||||
'TestSiteID' => 'permit_empty|is_natural_no_zero',
|
'TestSiteIDs.*' => 'is_natural_no_zero',
|
||||||
'ConditionExpr' => 'permit_empty|max_length[1000]',
|
'ConditionExpr' => 'permit_empty|max_length[1000]',
|
||||||
'Priority' => 'permit_empty|integer',
|
|
||||||
'Active' => 'permit_empty|in_list[0,1]',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$validation->run($input)) {
|
if (!$validation->run($input)) {
|
||||||
return $this->failValidationErrors($validation->getErrors());
|
return $this->failValidationErrors($validation->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopeType = $input['ScopeType'] ?? $existing['ScopeType'] ?? 'GLOBAL';
|
$db = \Config\Database::connect();
|
||||||
$testSiteID = array_key_exists('TestSiteID', $input) ? $input['TestSiteID'] : ($existing['TestSiteID'] ?? null);
|
$db->transStart();
|
||||||
|
|
||||||
if ($scopeType === 'TESTSITE') {
|
|
||||||
if (empty($testSiteID)) {
|
|
||||||
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']);
|
|
||||||
}
|
|
||||||
$testDef = new TestDefSiteModel();
|
|
||||||
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
|
|
||||||
if (!$exists) {
|
|
||||||
return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$testSiteID = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$updateData = [];
|
$updateData = [];
|
||||||
foreach (['Name', 'Description', 'EventCode', 'ScopeType', 'ConditionExpr', 'Priority', 'Active'] as $field) {
|
foreach (['RuleCode', 'RuleName', 'Description', 'EventCode', 'ConditionExpr', 'ConditionExprCompiled'] as $field) {
|
||||||
if (array_key_exists($field, $input)) {
|
if (array_key_exists($field, $input)) {
|
||||||
$updateData[$field] = $input[$field];
|
$updateData[$field] = $input[$field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$updateData['TestSiteID'] = $testSiteID;
|
|
||||||
|
|
||||||
if (!empty($updateData)) {
|
if (!empty($updateData)) {
|
||||||
$this->ruleDefModel->update((int) $id, $updateData);
|
$this->ruleDefModel->update((int) $id, $updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update test site mappings if provided
|
||||||
|
if (isset($input['TestSiteIDs']) && is_array($input['TestSiteIDs'])) {
|
||||||
|
$testSiteIDs = $input['TestSiteIDs'];
|
||||||
|
|
||||||
|
// Validate all TestSiteIDs exist
|
||||||
|
$testDef = new TestDefSiteModel();
|
||||||
|
foreach ($testSiteIDs as $testSiteID) {
|
||||||
|
$exists = $testDef->where('EndDate', null)->find((int) $testSiteID);
|
||||||
|
if (!$exists) {
|
||||||
|
throw new \Exception("TestSiteID {$testSiteID} not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current linked tests
|
||||||
|
$currentLinks = $this->ruleDefModel->getLinkedTests((int) $id);
|
||||||
|
|
||||||
|
// Unlink tests that are no longer in the list
|
||||||
|
foreach ($currentLinks as $currentTestSiteID) {
|
||||||
|
if (!in_array($currentTestSiteID, $testSiteIDs)) {
|
||||||
|
$this->ruleDefModel->unlinkTest((int) $id, $currentTestSiteID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link new tests
|
||||||
|
foreach ($testSiteIDs as $testSiteID) {
|
||||||
|
if (!in_array($testSiteID, $currentLinks)) {
|
||||||
|
$this->ruleDefModel->linkTest((int) $id, (int) $testSiteID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->transComplete();
|
||||||
|
if ($db->transStatus() === false) {
|
||||||
|
throw new \Exception('Transaction failed');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'Rule updated successfully',
|
'message' => 'Rule updated successfully',
|
||||||
'data' => ['RuleID' => (int) $id],
|
'data' => ['RuleID' => (int) $id],
|
||||||
], 200);
|
], 200);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
log_message('error', 'RuleController::update error: ' . $e->getMessage());
|
log_message('error', 'RuleController::update error: ' . $e->getMessage());
|
||||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
@ -341,4 +363,40 @@ class RuleController extends BaseController
|
|||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile DSL expression to engine-compatible structure.
|
||||||
|
* Frontend calls this when user clicks "Compile" button.
|
||||||
|
*/
|
||||||
|
public function compile()
|
||||||
|
{
|
||||||
|
$input = $this->request->getJSON(true) ?? [];
|
||||||
|
$expr = $input['expr'] ?? '';
|
||||||
|
|
||||||
|
if (!is_string($expr) || trim($expr) === '') {
|
||||||
|
return $this->failValidationErrors(['expr' => 'Expression is required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$svc = new RuleExpressionService();
|
||||||
|
$compiled = $svc->compile($expr);
|
||||||
|
|
||||||
|
return $this->respond([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'raw' => $expr,
|
||||||
|
'compiled' => $compiled,
|
||||||
|
'conditionExprCompiled' => json_encode($compiled),
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->respond([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Compilation failed',
|
||||||
|
'data' => [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
],
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -487,6 +487,13 @@ class TestsController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$memberIDs = $this->resolveCalcMemberIDs($data, $input);
|
$memberIDs = $this->resolveCalcMemberIDs($data, $input);
|
||||||
|
|
||||||
|
// Validate member IDs before insertion
|
||||||
|
$validation = $this->validateMemberIDs($memberIDs);
|
||||||
|
if (!$validation['valid']) {
|
||||||
|
throw new \Exception('Invalid member TestSiteID(s): ' . implode(', ', $validation['invalid']) . '. Make sure to use TestSiteID, not SeqScr or other values.');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($memberIDs as $memberID) {
|
foreach ($memberIDs as $memberID) {
|
||||||
$this->modelGrp->insert([
|
$this->modelGrp->insert([
|
||||||
'TestSiteID' => $testSiteID,
|
'TestSiteID' => $testSiteID,
|
||||||
@ -503,7 +510,8 @@ class TestsController extends BaseController
|
|||||||
if (is_array($rawMembers)) {
|
if (is_array($rawMembers)) {
|
||||||
foreach ($rawMembers as $member) {
|
foreach ($rawMembers as $member) {
|
||||||
if (is_array($member)) {
|
if (is_array($member)) {
|
||||||
$rawID = $member['Member'] ?? ($member['TestSiteID'] ?? null);
|
// Only accept TestSiteID, not Member (which might be SeqScr)
|
||||||
|
$rawID = $member['TestSiteID'] ?? null;
|
||||||
} else {
|
} else {
|
||||||
$rawID = is_numeric($member) ? $member : null;
|
$rawID = is_numeric($member) ? $member : null;
|
||||||
}
|
}
|
||||||
@ -519,6 +527,31 @@ class TestsController extends BaseController
|
|||||||
return $memberIDs;
|
return $memberIDs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that member IDs exist in testdefsite table
|
||||||
|
*
|
||||||
|
* @param array $memberIDs Array of TestSiteID values to validate
|
||||||
|
* @return array ['valid' => bool, 'invalid' => array]
|
||||||
|
*/
|
||||||
|
private function validateMemberIDs(array $memberIDs): array
|
||||||
|
{
|
||||||
|
if (empty($memberIDs)) {
|
||||||
|
return ['valid' => true, 'invalid' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->model->whereIn('TestSiteID', $memberIDs)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
$existingIDs = array_column($existing, 'TestSiteID');
|
||||||
|
$invalidIDs = array_diff($memberIDs, $existingIDs);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => empty($invalidIDs),
|
||||||
|
'invalid' => array_values($invalidIDs)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function saveGroupDetails($testSiteID, $data, $input, $action)
|
private function saveGroupDetails($testSiteID, $data, $input, $action)
|
||||||
{
|
{
|
||||||
if ($action === 'update') {
|
if ($action === 'update') {
|
||||||
@ -526,18 +559,32 @@ class TestsController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$members = $data['members'] ?? ($input['Members'] ?? []);
|
$members = $data['members'] ?? ($input['Members'] ?? []);
|
||||||
|
$memberIDs = [];
|
||||||
|
|
||||||
if (is_array($members)) {
|
if (is_array($members)) {
|
||||||
foreach ($members as $m) {
|
foreach ($members as $m) {
|
||||||
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m;
|
// Only accept TestSiteID, not Member (which might be SeqScr)
|
||||||
if ($memberID) {
|
$memberID = is_array($m) ? ($m['TestSiteID'] ?? null) : $m;
|
||||||
$this->modelGrp->insert([
|
if ($memberID && is_numeric($memberID)) {
|
||||||
'TestSiteID' => $testSiteID,
|
$memberIDs[] = (int) $memberID;
|
||||||
'Member' => $memberID,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$memberIDs = array_values(array_unique(array_filter($memberIDs)));
|
||||||
|
|
||||||
|
// Validate member IDs before insertion
|
||||||
|
$validation = $this->validateMemberIDs($memberIDs);
|
||||||
|
if (!$validation['valid']) {
|
||||||
|
throw new \Exception('Invalid member TestSiteID(s): ' . implode(', ', $validation['invalid']) . '. Make sure to use TestSiteID, not SeqScr or other values.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($memberIDs as $memberID) {
|
||||||
|
$this->modelGrp->insert([
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'Member' => $memberID,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action)
|
private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action)
|
||||||
|
|||||||
@ -4,39 +4,53 @@ namespace App\Database\Migrations;
|
|||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
class CreateRules extends Migration
|
/**
|
||||||
|
* Replace ruledef/ruleaction with testrule schema
|
||||||
|
* Rules can now be linked to multiple tests via testrule_testsite mapping table
|
||||||
|
*/
|
||||||
|
class CreateTestRules extends Migration
|
||||||
{
|
{
|
||||||
public function up()
|
public function up()
|
||||||
{
|
{
|
||||||
// ruledef
|
// ruledef - rule definitions (not linked to specific test)
|
||||||
$this->forge->addField([
|
$this->forge->addField([
|
||||||
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
||||||
'Name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
|
'RuleCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
||||||
|
'RuleName' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
|
||||||
'Description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
'Description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
'EventCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
'EventCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
||||||
'ScopeType' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'GLOBAL'],
|
|
||||||
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
|
|
||||||
'ConditionExpr' => ['type' => 'VARCHAR', 'constraint' => 1000, 'null' => true],
|
'ConditionExpr' => ['type' => 'VARCHAR', 'constraint' => 1000, 'null' => true],
|
||||||
'Priority' => ['type' => 'INT', 'null' => true, 'default' => 100],
|
'ConditionExprCompiled' => ['type' => 'JSON', 'null' => true],
|
||||||
'Active' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true, 'default' => 1],
|
|
||||||
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
'StartDate' => ['type' => 'DATETIME', 'null' => true],
|
'StartDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
'EndDate' => ['type' => 'DATETIME', 'null' => true],
|
'EndDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
]);
|
]);
|
||||||
$this->forge->addKey('RuleID', true);
|
$this->forge->addKey('RuleID', true);
|
||||||
|
$this->forge->addKey('RuleCode');
|
||||||
$this->forge->addKey('EventCode');
|
$this->forge->addKey('EventCode');
|
||||||
$this->forge->addKey('ScopeType');
|
|
||||||
$this->forge->addKey('TestSiteID');
|
|
||||||
$this->forge->createTable('ruledef');
|
$this->forge->createTable('ruledef');
|
||||||
|
|
||||||
// Optional scope FK (only when ScopeType=TESTSITE)
|
// testrule - mapping table for many-to-many relationship between ruledef and tests
|
||||||
$this->db->query('ALTER TABLE `ruledef` ADD CONSTRAINT `fk_ruledef_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
$this->forge->addField([
|
||||||
|
'TestRuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
|
||||||
|
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
|
||||||
|
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'EndDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('TestRuleID', true);
|
||||||
|
$this->forge->addKey('RuleID');
|
||||||
|
$this->forge->addKey('TestSiteID');
|
||||||
|
$this->forge->createTable('testrule');
|
||||||
|
|
||||||
// ruleaction
|
// Foreign keys for mapping table
|
||||||
|
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
|
$this->db->query('ALTER TABLE `testrule` ADD CONSTRAINT `fk_testrule_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE');
|
||||||
|
|
||||||
|
// ruleaction - actions for rules
|
||||||
$this->forge->addField([
|
$this->forge->addField([
|
||||||
'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
||||||
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
|
'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
|
||||||
'Seq' => ['type' => 'INT', 'null' => true, 'default' => 1],
|
|
||||||
'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
||||||
'ActionParams' => ['type' => 'TEXT', 'null' => true],
|
'ActionParams' => ['type' => 'TEXT', 'null' => true],
|
||||||
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
'CreateDate' => ['type' => 'DATETIME', 'null' => true],
|
||||||
@ -51,7 +65,8 @@ class CreateRules extends Migration
|
|||||||
|
|
||||||
public function down()
|
public function down()
|
||||||
{
|
{
|
||||||
$this->forge->dropTable('ruleaction');
|
$this->forge->dropTable('ruleaction', true);
|
||||||
$this->forge->dropTable('ruledef');
|
$this->forge->dropTable('testrule', true);
|
||||||
|
$this->forge->dropTable('ruledef', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -449,6 +449,55 @@ class OrderSeeder extends Seeder
|
|||||||
// Create order status
|
// Create order status
|
||||||
$this->createOrderStatus($internalOID5, 'ORDERED', $now);
|
$this->createOrderStatus($internalOID5, 'ORDERED', $now);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ORDER 6: Patient 2 - Bilirubin Panel (TBIL, DBIL, IBIL)
|
||||||
|
// ========================================
|
||||||
|
$orderID6 = '001' . date('ymd') . '00006';
|
||||||
|
$internalOID6 = $this->createOrder([
|
||||||
|
'OrderID' => $orderID6,
|
||||||
|
'PlacerID' => 'PLC006',
|
||||||
|
'InternalPID' => 2,
|
||||||
|
'SiteID' => '1',
|
||||||
|
'PVADTID' => 3,
|
||||||
|
'ReqApp' => 'HIS',
|
||||||
|
'Priority' => 'R',
|
||||||
|
'TrnDate' => $now,
|
||||||
|
'EffDate' => $now,
|
||||||
|
'CreateDate' => $now
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "\nCreated Order 6: {$orderID6} (InternalOID: {$internalOID6}) [Bilirubin Panel]\n";
|
||||||
|
|
||||||
|
// Create specimen for Bilirubin tests (SST tube - ConDefID = 1)
|
||||||
|
$specimenID6 = $orderID6 . '-S01';
|
||||||
|
$internalSID6 = $this->createSpecimen([
|
||||||
|
'SID' => $specimenID6,
|
||||||
|
'SiteID' => '1',
|
||||||
|
'OrderID' => $internalOID6,
|
||||||
|
'ConDefID' => 1,
|
||||||
|
'Qty' => 1,
|
||||||
|
'Unit' => 'tube',
|
||||||
|
'GenerateBy' => 'ORDER',
|
||||||
|
'CreateDate' => $now
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->createSpecimenStatus($specimenID6, $internalOID6, 'PENDING', $now);
|
||||||
|
$this->createSpecimenStatus($specimenID6, $internalOID6, 'COLLECTED', date('Y-m-d H:i:s', strtotime('+30 minutes')));
|
||||||
|
$this->createSpecimenStatus($specimenID6, $internalOID6, 'RECEIVED', date('Y-m-d H:i:s', strtotime('+2 hours')));
|
||||||
|
$this->createSpecimenStatus($specimenID6, $internalOID6, 'COMPLETED', date('Y-m-d H:i:s', strtotime('+6 hours')));
|
||||||
|
|
||||||
|
echo " Created Specimen: {$specimenID6} (SST)\n";
|
||||||
|
|
||||||
|
// Create order status
|
||||||
|
$this->createOrderStatus($internalOID6, 'ORDERED', $now);
|
||||||
|
$this->createOrderStatus($internalOID6, 'SPECIMEN_COLLECTED', date('Y-m-d H:i:s', strtotime('+30 minutes')));
|
||||||
|
$this->createOrderStatus($internalOID6, 'IN_LAB', date('Y-m-d H:i:s', strtotime('+2 hours')));
|
||||||
|
$this->createOrderStatus($internalOID6, 'COMPLETED', date('Y-m-d H:i:s', strtotime('+6 hours')));
|
||||||
|
$this->createOrderStatus($internalOID6, 'REPORTED', date('Y-m-d H:i:s', strtotime('+7 hours')));
|
||||||
|
|
||||||
|
// Create order comment
|
||||||
|
$this->createOrderComment($internalOID6, 'Bilirubin panel ordered for liver function assessment', $now);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// SUMMARY
|
// SUMMARY
|
||||||
// ========================================
|
// ========================================
|
||||||
@ -456,15 +505,16 @@ class OrderSeeder extends Seeder
|
|||||||
echo "========================================\n";
|
echo "========================================\n";
|
||||||
echo "ORDER SEEDING COMPLETED SUCCESSFULLY!\n";
|
echo "ORDER SEEDING COMPLETED SUCCESSFULLY!\n";
|
||||||
echo "========================================\n";
|
echo "========================================\n";
|
||||||
echo "Orders Created: 5\n";
|
echo "Orders Created: 6\n";
|
||||||
echo " - Order 1: CBC (Complete Blood Count) - COMPLETED\n";
|
echo " - Order 1: CBC (Complete Blood Count) - COMPLETED\n";
|
||||||
echo " - Order 2: Lipid + Liver Profile - COMPLETED\n";
|
echo " - Order 2: Lipid + Liver Profile - COMPLETED\n";
|
||||||
echo " - Order 3: Renal + Glucose - COMPLETED (URGENT)\n";
|
echo " - Order 3: Renal + Glucose - COMPLETED (URGENT)\n";
|
||||||
echo " - Order 4: Urinalysis - PENDING\n";
|
echo " - Order 4: Urinalysis - PENDING\n";
|
||||||
echo " - Order 5: CBC + Chemistry - PENDING (Multi-container)\n";
|
echo " - Order 5: CBC + Chemistry - PENDING (Multi-container)\n";
|
||||||
|
echo " - Order 6: Bilirubin Panel (TBIL, DBIL, IBIL) - COMPLETED\n";
|
||||||
echo "----------------------------------------\n";
|
echo "----------------------------------------\n";
|
||||||
echo "Specimens Created: 6\n";
|
echo "Specimens Created: 7\n";
|
||||||
echo " - SST tubes: 4\n";
|
echo " - SST tubes: 5\n";
|
||||||
echo " - EDTA tubes: 2\n";
|
echo " - EDTA tubes: 2\n";
|
||||||
echo " - Urine containers: 1\n";
|
echo " - Urine containers: 1\n";
|
||||||
echo "----------------------------------------\n";
|
echo "----------------------------------------\n";
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"VSName": "Enable/Disable",
|
"VSName": "Enable/Disable",
|
||||||
"VCategory": "System",
|
"VCategory": "System",
|
||||||
"values": [
|
"values": [
|
||||||
{"key": "0", "value": "Disabled"},
|
{"key": "D", "value": "Disabled"},
|
||||||
{"key": "1", "value": "Enabled"}
|
{"key": "E", "value": "Enabled"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
"VSName": "Sex",
|
"VSName": "Sex",
|
||||||
"VCategory": "System",
|
"VCategory": "System",
|
||||||
"values": [
|
"values": [
|
||||||
{"key": "1", "value": "Female"},
|
{"key": "F", "value": "Female"},
|
||||||
{"key": "2", "value": "Male"},
|
{"key": "M", "value": "Male"},
|
||||||
{"key": "3", "value": "Unknown"}
|
{"key": "U", "value": "Unknown"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"VSName": "Workstation Type",
|
"VSName": "Workstation Type",
|
||||||
"VCategory": "System",
|
"VCategory": "System",
|
||||||
"values": [
|
"values": [
|
||||||
{"key": "0", "value": "Primary"},
|
{"key": "PRI", "value": "Primary"},
|
||||||
{"key": "1", "value": "Secondary"}
|
{"key": "SEC", "value": "Secondary"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,17 @@ namespace App\Models\Rule;
|
|||||||
|
|
||||||
use App\Models\BaseModel;
|
use App\Models\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleAction Model
|
||||||
|
*
|
||||||
|
* Actions that can be executed when a rule matches.
|
||||||
|
*/
|
||||||
class RuleActionModel extends BaseModel
|
class RuleActionModel extends BaseModel
|
||||||
{
|
{
|
||||||
protected $table = 'ruleaction';
|
protected $table = 'ruleaction';
|
||||||
protected $primaryKey = 'RuleActionID';
|
protected $primaryKey = 'RuleActionID';
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'RuleID',
|
'RuleID',
|
||||||
'Seq',
|
|
||||||
'ActionType',
|
'ActionType',
|
||||||
'ActionParams',
|
'ActionParams',
|
||||||
'CreateDate',
|
'CreateDate',
|
||||||
@ -24,6 +28,12 @@ class RuleActionModel extends BaseModel
|
|||||||
protected $useSoftDeletes = true;
|
protected $useSoftDeletes = true;
|
||||||
protected $deletedField = 'EndDate';
|
protected $deletedField = 'EndDate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active actions by rule IDs
|
||||||
|
*
|
||||||
|
* @param array $ruleIDs Array of RuleID values
|
||||||
|
* @return array Array of actions
|
||||||
|
*/
|
||||||
public function getActiveByRuleIDs(array $ruleIDs): array
|
public function getActiveByRuleIDs(array $ruleIDs): array
|
||||||
{
|
{
|
||||||
if (empty($ruleIDs)) {
|
if (empty($ruleIDs)) {
|
||||||
@ -33,7 +43,6 @@ class RuleActionModel extends BaseModel
|
|||||||
return $this->whereIn('RuleID', $ruleIDs)
|
return $this->whereIn('RuleID', $ruleIDs)
|
||||||
->where('EndDate IS NULL')
|
->where('EndDate IS NULL')
|
||||||
->orderBy('RuleID', 'ASC')
|
->orderBy('RuleID', 'ASC')
|
||||||
->orderBy('Seq', 'ASC')
|
|
||||||
->orderBy('RuleActionID', 'ASC')
|
->orderBy('RuleActionID', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,19 +4,22 @@ namespace App\Models\Rule;
|
|||||||
|
|
||||||
use App\Models\BaseModel;
|
use App\Models\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleDef Model
|
||||||
|
*
|
||||||
|
* Rule definitions that can be linked to multiple tests via testrule mapping table.
|
||||||
|
*/
|
||||||
class RuleDefModel extends BaseModel
|
class RuleDefModel extends BaseModel
|
||||||
{
|
{
|
||||||
protected $table = 'ruledef';
|
protected $table = 'ruledef';
|
||||||
protected $primaryKey = 'RuleID';
|
protected $primaryKey = 'RuleID';
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'Name',
|
'RuleCode',
|
||||||
|
'RuleName',
|
||||||
'Description',
|
'Description',
|
||||||
'EventCode',
|
'EventCode',
|
||||||
'ScopeType',
|
|
||||||
'TestSiteID',
|
|
||||||
'ConditionExpr',
|
'ConditionExpr',
|
||||||
'Priority',
|
'ConditionExprCompiled',
|
||||||
'Active',
|
|
||||||
'CreateDate',
|
'CreateDate',
|
||||||
'StartDate',
|
'StartDate',
|
||||||
'EndDate',
|
'EndDate',
|
||||||
@ -30,32 +33,108 @@ class RuleDefModel extends BaseModel
|
|||||||
protected $deletedField = 'EndDate';
|
protected $deletedField = 'EndDate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch active rules for an event, optionally scoped.
|
* Fetch active rules for an event scoped by TestSiteID.
|
||||||
*
|
*
|
||||||
* Scope behavior:
|
* Rules are standalone and only apply when explicitly linked to a test
|
||||||
* - Always returns GLOBAL rules
|
* via the testrule mapping table.
|
||||||
* - If $testSiteID provided, also returns TESTSITE rules matching TestSiteID
|
*
|
||||||
|
* @param string $eventCode The event code to filter by
|
||||||
|
* @param int|null $testSiteID The test site ID to filter by
|
||||||
|
* @return array Array of matching rules
|
||||||
*/
|
*/
|
||||||
public function getActiveByEvent(string $eventCode, ?int $testSiteID = null): array
|
public function getActiveByEvent(string $eventCode, ?int $testSiteID = null): array
|
||||||
{
|
{
|
||||||
$builder = $this->where('EventCode', $eventCode)
|
if ($testSiteID === null) {
|
||||||
->where('EndDate IS NULL')
|
return [];
|
||||||
->where('Active', 1)
|
|
||||||
->groupStart()
|
|
||||||
->where('ScopeType', 'GLOBAL');
|
|
||||||
|
|
||||||
if ($testSiteID !== null) {
|
|
||||||
$builder->orGroupStart()
|
|
||||||
->where('ScopeType', 'TESTSITE')
|
|
||||||
->where('TestSiteID', $testSiteID)
|
|
||||||
->groupEnd();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder->groupEnd();
|
return $this->select('ruledef.*')
|
||||||
|
->join('testrule', 'testrule.RuleID = ruledef.RuleID', 'inner')
|
||||||
return $builder
|
->where('ruledef.EventCode', $eventCode)
|
||||||
->orderBy('Priority', 'ASC')
|
->where('ruledef.EndDate IS NULL')
|
||||||
->orderBy('RuleID', 'ASC')
|
->where('testrule.TestSiteID', $testSiteID)
|
||||||
|
->where('testrule.EndDate IS NULL')
|
||||||
|
->orderBy('ruledef.RuleID', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tests linked to a rule
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @return array Array of test site IDs
|
||||||
|
*/
|
||||||
|
public function getLinkedTests(int $ruleID): array
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$result = $db->table('testrule')
|
||||||
|
->where('RuleID', $ruleID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->select('TestSiteID')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
return array_column($result, 'TestSiteID');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a rule to a test
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @param int $testSiteID The test site ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function linkTest(int $ruleID, int $testSiteID): bool
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Check if already linked (and not soft deleted)
|
||||||
|
$existing = $db->table('testrule')
|
||||||
|
->where('RuleID', $ruleID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return true; // Already linked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if soft deleted - restore it
|
||||||
|
$softDeleted = $db->table('testrule')
|
||||||
|
->where('RuleID', $ruleID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('EndDate IS NOT NULL')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($softDeleted) {
|
||||||
|
return $db->table('testrule')
|
||||||
|
->where('TestRuleID', $softDeleted['TestRuleID'])
|
||||||
|
->update(['EndDate' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new link
|
||||||
|
return $db->table('testrule')->insert([
|
||||||
|
'RuleID' => $ruleID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a rule from a test (soft delete)
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @param int $testSiteID The test site ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function unlinkTest(int $ruleID, int $testSiteID): bool
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
return $db->table('testrule')
|
||||||
|
->where('RuleID', $ruleID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->update(['EndDate' => date('Y-m-d H:i:s')]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
app/Models/Rule/TestRuleModel.php
Normal file
71
app/Models/Rule/TestRuleModel.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Rule;
|
||||||
|
|
||||||
|
use App\Models\BaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestRule Model
|
||||||
|
*
|
||||||
|
* Mapping table linking ruledef to test sites.
|
||||||
|
* Represents which tests a rule is linked to.
|
||||||
|
*/
|
||||||
|
class TestRuleModel extends BaseModel
|
||||||
|
{
|
||||||
|
protected $table = 'testrule';
|
||||||
|
protected $primaryKey = 'TestRuleID';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'RuleID',
|
||||||
|
'TestSiteID',
|
||||||
|
'CreateDate',
|
||||||
|
'EndDate',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'CreateDate';
|
||||||
|
protected $updatedField = '';
|
||||||
|
|
||||||
|
protected $useSoftDeletes = true;
|
||||||
|
protected $deletedField = 'EndDate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active test site mappings for a rule
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @return array Array of mappings with test site details
|
||||||
|
*/
|
||||||
|
public function getByRuleID(int $ruleID): array
|
||||||
|
{
|
||||||
|
return $this->where('RuleID', $ruleID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active rules mapped to a test site
|
||||||
|
*
|
||||||
|
* @param int $testSiteID The test site ID
|
||||||
|
* @return array Array of mappings
|
||||||
|
*/
|
||||||
|
public function getByTestSiteID(int $testSiteID): array
|
||||||
|
{
|
||||||
|
return $this->where('TestSiteID', $testSiteID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a rule is linked to a test site
|
||||||
|
*
|
||||||
|
* @param int $ruleID The rule ID
|
||||||
|
* @param int $testSiteID The test site ID
|
||||||
|
* @return bool True if linked and active
|
||||||
|
*/
|
||||||
|
public function isLinked(int $ruleID, int $testSiteID): bool
|
||||||
|
{
|
||||||
|
return $this->where('RuleID', $ruleID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,6 +51,34 @@ class TestDefCalModel extends BaseModel {
|
|||||||
->get()->getRowArray();
|
->get()->getRowArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an active calculation by TestSiteCode or TestSiteName (case-insensitive).
|
||||||
|
*/
|
||||||
|
public function findActiveByCodeOrName(string $codeOrName): ?array
|
||||||
|
{
|
||||||
|
$identifier = mb_strtolower(trim($codeOrName));
|
||||||
|
|
||||||
|
if ($identifier === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('testdefcal cal')
|
||||||
|
->select('cal.*, site.TestSiteCode, site.TestSiteName')
|
||||||
|
->join('testdefsite site', 'site.TestSiteID=cal.TestSiteID', 'left')
|
||||||
|
->where('cal.EndDate IS NULL')
|
||||||
|
->where('site.EndDate IS NULL')
|
||||||
|
->groupStart()
|
||||||
|
->where('LOWER(site.TestSiteCode)', $identifier)
|
||||||
|
->orWhere('LOWER(site.TestSiteName)', $identifier)
|
||||||
|
->groupEnd()
|
||||||
|
->orderBy('cal.CreateDate', 'DESC')
|
||||||
|
->limit(1);
|
||||||
|
|
||||||
|
$row = $builder->get()->getRowArray();
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable calculation by TestSiteID
|
* Disable calculation by TestSiteID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -38,8 +38,8 @@ class CalculatorService {
|
|||||||
*/
|
*/
|
||||||
public function calculate(string $formula, array $variables = []): ?float {
|
public function calculate(string $formula, array $variables = []): ?float {
|
||||||
try {
|
try {
|
||||||
// Convert placeholders to math-parser compatible format
|
$normalizedFormula = $this->normalizeFormulaVariables($formula, $variables);
|
||||||
$expression = $this->prepareExpression($formula, $variables);
|
$expression = $this->prepareExpression($normalizedFormula, $variables);
|
||||||
|
|
||||||
// Parse the expression
|
// Parse the expression
|
||||||
$ast = $this->parser->parse($expression);
|
$ast = $this->parser->parse($expression);
|
||||||
@ -115,6 +115,32 @@ class CalculatorService {
|
|||||||
return $expression;
|
return $expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize formulas that reference raw variable names instead of placeholders.
|
||||||
|
*/
|
||||||
|
protected function normalizeFormulaVariables(string $formula, array $variables): string
|
||||||
|
{
|
||||||
|
if (str_contains($formula, '{')) {
|
||||||
|
return $formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($variables)) {
|
||||||
|
return $formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($variables);
|
||||||
|
usort($keys, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a));
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$escaped = preg_quote($key, '/');
|
||||||
|
$formula = preg_replace_callback('/\b' . $escaped . '\b/i', function () use ($key) {
|
||||||
|
return '{' . $key . '}';
|
||||||
|
}, $formula);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formula;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize gender value to numeric (0, 1, or 2)
|
* Normalize gender value to numeric (0, 1, or 2)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -59,13 +59,34 @@ class RuleEngineService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context);
|
// Check for compiled expression first
|
||||||
if (!$matches) {
|
$compiled = null;
|
||||||
continue;
|
if (!empty($rule['ConditionExprCompiled'])) {
|
||||||
|
$compiled = json_decode($rule['ConditionExprCompiled'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($actionsByRule[$rid] ?? [] as $action) {
|
if (!empty($compiled) && is_array($compiled)) {
|
||||||
$this->executeAction($action, $context);
|
// Compiled rule: evaluate condition from compiled structure
|
||||||
|
$conditionExpr = $compiled['conditionExpr'] ?? 'true';
|
||||||
|
$matches = $this->expr->evaluateBoolean($conditionExpr, $context);
|
||||||
|
if (!$matches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use compiled valueExpr for SET_RESULT action
|
||||||
|
if (!empty($compiled['valueExpr'])) {
|
||||||
|
$this->executeCompiledSetResult($rid, $compiled['valueExpr'], $context);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy rule: evaluate raw ConditionExpr and execute stored actions
|
||||||
|
$matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context);
|
||||||
|
if (!$matches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($actionsByRule[$rid] ?? [] as $action) {
|
||||||
|
$this->executeAction($action, $context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
|
log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage());
|
||||||
@ -74,6 +95,71 @@ class RuleEngineService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute SET_RESULT action using compiled valueExpr.
|
||||||
|
* Automatically creates the test result if it doesn't exist.
|
||||||
|
*/
|
||||||
|
protected function executeCompiledSetResult(int $ruleID, string $valueExpr, array $context): void
|
||||||
|
{
|
||||||
|
$order = $context['order'] ?? null;
|
||||||
|
if (!is_array($order) || empty($order['InternalOID'])) {
|
||||||
|
throw new \Exception('SET_RESULT requires context.order.InternalOID');
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalOID = (int) $order['InternalOID'];
|
||||||
|
$testSiteID = $context['testSiteID'] ?? null;
|
||||||
|
|
||||||
|
if ($testSiteID === null && isset($order['TestSiteID'])) {
|
||||||
|
$testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testSiteID === null) {
|
||||||
|
// Try to get testSiteID from context tests
|
||||||
|
$tests = $context['tests'] ?? [];
|
||||||
|
if (!empty($tests) && is_array($tests)) {
|
||||||
|
$testSiteID = (int) ($tests[0]['TestSiteID'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testSiteID === null) {
|
||||||
|
throw new \Exception('SET_RESULT requires testSiteID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate the value expression
|
||||||
|
$value = $this->expr->evaluate($valueExpr, $context);
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Check if patres row exists
|
||||||
|
$patres = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('DelDate', null)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if ($patres) {
|
||||||
|
// Update existing result
|
||||||
|
$ok = $db->table('patres')
|
||||||
|
->where('OrderID', $internalOID)
|
||||||
|
->where('TestSiteID', $testSiteID)
|
||||||
|
->where('DelDate', null)
|
||||||
|
->update(['Result' => $value]);
|
||||||
|
} else {
|
||||||
|
// Insert new result row
|
||||||
|
$ok = $db->table('patres')->insert([
|
||||||
|
'OrderID' => $internalOID,
|
||||||
|
'TestSiteID' => $testSiteID,
|
||||||
|
'Result' => $value,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ok === false) {
|
||||||
|
throw new \Exception('SET_RESULT update/insert failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function executeAction(array $action, array $context): void
|
protected function executeAction(array $action, array $context): void
|
||||||
{
|
{
|
||||||
$type = strtoupper((string) ($action['ActionType'] ?? ''));
|
$type = strtoupper((string) ($action['ActionType'] ?? ''));
|
||||||
|
|||||||
@ -43,4 +43,141 @@ class RuleExpressionService
|
|||||||
|
|
||||||
return (bool) $this->evaluate($expr, $context);
|
return (bool) $this->evaluate($expr, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile DSL expression to engine-compatible JSON structure.
|
||||||
|
*
|
||||||
|
* Supported DSL:
|
||||||
|
* - if(condition ? action : action)
|
||||||
|
* - sex('F'|'M') -> order["Sex"] == 'F'
|
||||||
|
* - set_result(value) -> {"value": value} or {"valueExpr": "value"}
|
||||||
|
*
|
||||||
|
* @param string $expr The raw DSL expression
|
||||||
|
* @return array The compiled structure with valueExpr
|
||||||
|
* @throws \InvalidArgumentException If DSL is invalid
|
||||||
|
*/
|
||||||
|
public function compile(string $expr): array
|
||||||
|
{
|
||||||
|
$expr = trim($expr);
|
||||||
|
if ($expr === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove outer parentheses from if(...)
|
||||||
|
if (preg_match('/^if\s*\(\s*(.+?)\s*\)$/s', $expr, $m)) {
|
||||||
|
$expr = trim($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: condition ? thenAction : elseAction
|
||||||
|
if (!preg_match('/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/s', $expr, $parts)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid DSL: expected "if(condition ? action : action)" format');
|
||||||
|
}
|
||||||
|
|
||||||
|
$condition = trim($parts[1]);
|
||||||
|
$thenAction = trim($parts[2]);
|
||||||
|
$elseAction = trim($parts[3]);
|
||||||
|
|
||||||
|
// Compile condition
|
||||||
|
$compiledCondition = $this->compileCondition($condition);
|
||||||
|
|
||||||
|
// Compile actions
|
||||||
|
$thenCompiled = $this->compileAction($thenAction);
|
||||||
|
$elseCompiled = $this->compileAction($elseAction);
|
||||||
|
|
||||||
|
// Build valueExpr combining condition and actions
|
||||||
|
$thenValue = $thenCompiled['valueExpr'] ?? json_encode($thenCompiled['value'] ?? null);
|
||||||
|
$elseValue = $elseCompiled['valueExpr'] ?? json_encode($elseCompiled['value'] ?? null);
|
||||||
|
|
||||||
|
// Handle string vs numeric values
|
||||||
|
if (is_string($thenCompiled['value'] ?? null)) {
|
||||||
|
$thenValue = '"' . addslashes($thenCompiled['value']) . '"';
|
||||||
|
}
|
||||||
|
if (is_string($elseCompiled['value'] ?? null)) {
|
||||||
|
$elseValue = '"' . addslashes($elseCompiled['value']) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
$valueExpr = "({$compiledCondition}) ? {$thenValue} : {$elseValue}";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'conditionExpr' => $compiledCondition,
|
||||||
|
'valueExpr' => $valueExpr,
|
||||||
|
'then' => $thenCompiled,
|
||||||
|
'else' => $elseCompiled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile DSL condition to ExpressionLanguage expression
|
||||||
|
*/
|
||||||
|
private function compileCondition(string $condition): string
|
||||||
|
{
|
||||||
|
$condition = trim($condition);
|
||||||
|
|
||||||
|
// sex('F') -> order["Sex"] == 'F'
|
||||||
|
if (preg_match("/^sex\s*\(\s*['\"]([MF])['\"]\s*\)$/i", $condition, $m)) {
|
||||||
|
return 'order["Sex"] == "' . $m[1] . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// sex == 'F' (alternative syntax)
|
||||||
|
if (preg_match('/^\s*sex\s*==\s*[\'"]([MF])[\'"]\s*$/i', $condition, $m)) {
|
||||||
|
return 'order["Sex"] == "' . $m[1] . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// priority('S') -> order["Priority"] == 'S'
|
||||||
|
if (preg_match("/^priority\s*\(\s*['\"]([SR])['\"]\s*\)$/i", $condition, $m)) {
|
||||||
|
return 'order["Priority"] == "' . $m[1] . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// priority == 'S' (alternative syntax)
|
||||||
|
if (preg_match('/^\s*priority\s*==\s*[\'"]([SR])[\'"]\s*$/i', $condition, $m)) {
|
||||||
|
return 'order["Priority"] == "' . $m[1] . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// age > 18 -> patient["Age"] > 18 (if available) or order["Age"] > 18
|
||||||
|
if (preg_match('/^\s*age\s*([<>]=?)\s*(\d+)\s*$/i', $condition, $m)) {
|
||||||
|
return 'order["Age"] ' . $m[1] . ' ' . $m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already valid ExpressionLanguage, return as-is
|
||||||
|
return $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile DSL action to action params
|
||||||
|
*/
|
||||||
|
private function compileAction(string $action): array
|
||||||
|
{
|
||||||
|
$action = trim($action);
|
||||||
|
|
||||||
|
// set_result(value) -> SET_RESULT action
|
||||||
|
if (preg_match('/^set_result\s*\(\s*(.+?)\s*\)$/i', $action, $m)) {
|
||||||
|
$value = trim($m[1]);
|
||||||
|
|
||||||
|
// Check if it's a number
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return [
|
||||||
|
'type' => 'SET_RESULT',
|
||||||
|
'value' => strpos($value, '.') !== false ? (float) $value : (int) $value,
|
||||||
|
'valueExpr' => $value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a quoted string
|
||||||
|
if (preg_match('/^["\'](.+)["\']$/s', $value, $vm)) {
|
||||||
|
return [
|
||||||
|
'type' => 'SET_RESULT',
|
||||||
|
'value' => $vm[1],
|
||||||
|
'valueExpr' => '"' . addslashes($vm[1]) . '"',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex expression
|
||||||
|
return [
|
||||||
|
'type' => 'SET_RESULT',
|
||||||
|
'valueExpr' => $value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException('Unknown action: ' . $action);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@ tags:
|
|||||||
description: Specimen and container management
|
description: Specimen and container management
|
||||||
- name: Tests
|
- name: Tests
|
||||||
description: Test definitions and test catalog
|
description: Test definitions and test catalog
|
||||||
|
- name: Calculations
|
||||||
|
description: Lightweight calculator endpoint for retrieving computed values by code or name
|
||||||
- name: Orders
|
- name: Orders
|
||||||
description: Laboratory order management
|
description: Laboratory order management
|
||||||
- name: Results
|
- name: Results
|
||||||
@ -54,7 +56,7 @@ tags:
|
|||||||
- name: Users
|
- name: Users
|
||||||
description: User management and administration
|
description: User management and administration
|
||||||
- name: Rules
|
- name: Rules
|
||||||
description: Common rule engine (events, conditions, actions)
|
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
|
||||||
paths:
|
paths:
|
||||||
/api/auth/login:
|
/api/auth/login:
|
||||||
post:
|
post:
|
||||||
@ -224,6 +226,44 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: User created
|
description: User created
|
||||||
|
/api/calc/{codeOrName}:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Calculations
|
||||||
|
summary: Evaluate a configured calculation by test code or name and return the numeric result only.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: codeOrName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: TestSiteCode or TestSiteName of the calculated test (case-insensitive).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Key-value pairs where keys match member tests used in the formula.
|
||||||
|
additionalProperties:
|
||||||
|
type: number
|
||||||
|
example:
|
||||||
|
TBIL: 5
|
||||||
|
DBIL: 3
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
value:
|
||||||
|
IBIL: 2
|
||||||
|
incomplete:
|
||||||
|
value: {}
|
||||||
/api/contact:
|
/api/contact:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -1051,13 +1091,6 @@ paths:
|
|||||||
VER: Verified
|
VER: Verified
|
||||||
REV: Reviewed
|
REV: Reviewed
|
||||||
REP: Reported
|
REP: Reported
|
||||||
- name: include
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- details
|
|
||||||
description: Include specimens and tests in response
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of orders
|
description: List of orders
|
||||||
@ -1073,7 +1106,7 @@ paths:
|
|||||||
data:
|
data:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/OrderTest'
|
$ref: '#/components/schemas/OrderTestList'
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Orders
|
- Orders
|
||||||
@ -3071,32 +3104,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Filter by event code
|
description: Filter by event code
|
||||||
- name: Active
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
enum:
|
|
||||||
- 0
|
|
||||||
- 1
|
|
||||||
description: Filter by active flag
|
|
||||||
- name: ScopeType
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- GLOBAL
|
|
||||||
- TESTSITE
|
|
||||||
description: Filter by scope type
|
|
||||||
- name: TestSiteID
|
- name: TestSiteID
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: Filter by TestSiteID (for TESTSITE scope)
|
description: Filter by TestSiteID (returns rules linked to this test). Rules are only returned when attached to tests.
|
||||||
- name: search
|
- name: search
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Search by rule name
|
description: Search by rule code or name
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of rules
|
description: List of rules
|
||||||
@ -3117,6 +3134,9 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Rules
|
- Rules
|
||||||
summary: Create rule
|
summary: Create rule
|
||||||
|
description: |
|
||||||
|
Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
|
||||||
|
A single rule can be linked to multiple tests. Rules are active only when attached to tests.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@ -3126,48 +3146,47 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Name:
|
RuleCode:
|
||||||
type: string
|
type: string
|
||||||
|
example: AUTO_SET_RESULT
|
||||||
|
RuleName:
|
||||||
|
type: string
|
||||||
|
example: Automatically Set Result
|
||||||
Description:
|
Description:
|
||||||
type: string
|
type: string
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
example: ORDER_CREATED
|
example: ORDER_CREATED
|
||||||
ScopeType:
|
TestSiteIDs:
|
||||||
type: string
|
type: array
|
||||||
enum:
|
items:
|
||||||
- GLOBAL
|
type: integer
|
||||||
- TESTSITE
|
description: Array of TestSiteIDs to link this rule to (required)
|
||||||
TestSiteID:
|
example:
|
||||||
type: integer
|
- 1
|
||||||
nullable: true
|
- 2
|
||||||
|
- 3
|
||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
Priority:
|
example: order["Priority"] == "S"
|
||||||
type: integer
|
|
||||||
Active:
|
|
||||||
type: integer
|
|
||||||
enum:
|
|
||||||
- 0
|
|
||||||
- 1
|
|
||||||
actions:
|
actions:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
|
example: SET_RESULT
|
||||||
ActionParams:
|
ActionParams:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: object
|
- type: object
|
||||||
required:
|
required:
|
||||||
- Name
|
- RuleCode
|
||||||
|
- RuleName
|
||||||
- EventCode
|
- EventCode
|
||||||
- ScopeType
|
- TestSiteIDs
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Rule created
|
description: Rule created
|
||||||
@ -3175,7 +3194,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Rules
|
- Rules
|
||||||
summary: Get rule (with actions)
|
summary: Get rule with actions and linked tests
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -3184,9 +3203,10 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule details
|
description: Rule details with actions and linked test sites
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -3197,13 +3217,17 @@ paths:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/RuleWithActions'
|
$ref: '#/components/schemas/RuleWithDetails'
|
||||||
'404':
|
'404':
|
||||||
description: Rule not found
|
description: Rule not found
|
||||||
patch:
|
patch:
|
||||||
tags:
|
tags:
|
||||||
- Rules
|
- Rules
|
||||||
summary: Update rule
|
summary: Update rule
|
||||||
|
description: |
|
||||||
|
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
|
||||||
|
Tests not in the new list will be unlinked, and new tests will be linked.
|
||||||
|
Rules are active only when attached to tests.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -3212,6 +3236,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -3219,30 +3244,22 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Name:
|
RuleCode:
|
||||||
|
type: string
|
||||||
|
RuleName:
|
||||||
type: string
|
type: string
|
||||||
Description:
|
Description:
|
||||||
type: string
|
type: string
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
ScopeType:
|
TestSiteIDs:
|
||||||
type: string
|
type: array
|
||||||
enum:
|
items:
|
||||||
- GLOBAL
|
type: integer
|
||||||
- TESTSITE
|
description: Array of TestSiteIDs to link this rule to
|
||||||
TestSiteID:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
Priority:
|
|
||||||
type: integer
|
|
||||||
Active:
|
|
||||||
type: integer
|
|
||||||
enum:
|
|
||||||
- 0
|
|
||||||
- 1
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule updated
|
description: Rule updated
|
||||||
@ -3260,6 +3277,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule deleted
|
description: Rule deleted
|
||||||
@ -3289,6 +3307,55 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Validation result
|
description: Validation result
|
||||||
|
/api/rules/compile:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Rules
|
||||||
|
summary: Compile DSL expression to engine-compatible structure
|
||||||
|
description: |
|
||||||
|
Compile a DSL expression to the engine-compatible JSON structure.
|
||||||
|
Frontend calls this when user clicks "Compile" button.
|
||||||
|
Returns compiled structure that can be saved to ConditionExprCompiled field.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
expr:
|
||||||
|
type: string
|
||||||
|
description: Raw DSL expression
|
||||||
|
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
|
||||||
|
required:
|
||||||
|
- expr
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Compilation successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
raw:
|
||||||
|
type: string
|
||||||
|
description: Original DSL expression
|
||||||
|
compiled:
|
||||||
|
type: object
|
||||||
|
description: Parsed structure with conditionExpr, valueExpr, then, else
|
||||||
|
conditionExprCompiled:
|
||||||
|
type: string
|
||||||
|
description: JSON string to save to ConditionExprCompiled field
|
||||||
|
'400':
|
||||||
|
description: Compilation failed (invalid syntax)
|
||||||
/api/rules/{id}/actions:
|
/api/rules/{id}/actions:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -3302,6 +3369,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Actions list
|
description: Actions list
|
||||||
@ -3330,6 +3398,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -3337,10 +3406,9 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
|
example: SET_RESULT
|
||||||
ActionParams:
|
ActionParams:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
@ -3363,11 +3431,13 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
- name: actionId
|
- name: actionId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleActionID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -3375,8 +3445,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
ActionParams:
|
ActionParams:
|
||||||
@ -3398,11 +3466,13 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
- name: actionId
|
- name: actionId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleActionID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Action deleted
|
description: Action deleted
|
||||||
@ -4433,7 +4503,62 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
details:
|
details:
|
||||||
type: object
|
type: object
|
||||||
description: Type-specific details
|
description: |
|
||||||
|
Type-specific details. For CALC and GROUP types, include members array.
|
||||||
|
|
||||||
|
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||||
|
Invalid TestSiteIDs will result in a 400 error.
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
DepartmentID:
|
||||||
|
type: integer
|
||||||
|
ResultType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- NMRIC
|
||||||
|
- RANGE
|
||||||
|
- TEXT
|
||||||
|
- VSET
|
||||||
|
- NORES
|
||||||
|
RefType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- RANGE
|
||||||
|
- THOLD
|
||||||
|
- VSET
|
||||||
|
- TEXT
|
||||||
|
- NOREF
|
||||||
|
FormulaCode:
|
||||||
|
type: string
|
||||||
|
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||||
|
Unit1:
|
||||||
|
type: string
|
||||||
|
Factor:
|
||||||
|
type: number
|
||||||
|
Unit2:
|
||||||
|
type: string
|
||||||
|
Decimal:
|
||||||
|
type: integer
|
||||||
|
default: 2
|
||||||
|
Method:
|
||||||
|
type: string
|
||||||
|
ExpectedTAT:
|
||||||
|
type: integer
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Array of member tests for CALC and GROUP types.
|
||||||
|
Each member object must contain `TestSiteID` (the actual test ID).
|
||||||
|
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: The actual TestSiteID of the member test (required)
|
||||||
|
required:
|
||||||
|
- TestSiteID
|
||||||
refnum:
|
refnum:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -4451,6 +4576,30 @@ paths:
|
|||||||
- TestSiteCode
|
- TestSiteCode
|
||||||
- TestSiteName
|
- TestSiteName
|
||||||
- TestType
|
- TestType
|
||||||
|
examples:
|
||||||
|
CALC_test:
|
||||||
|
summary: Create calculated test with members
|
||||||
|
value:
|
||||||
|
SiteID: 1
|
||||||
|
TestSiteCode: IBIL
|
||||||
|
TestSiteName: Indirect Bilirubin
|
||||||
|
TestType: CALC
|
||||||
|
Description: Bilirubin Indirek
|
||||||
|
SeqScr: 210
|
||||||
|
SeqRpt: 210
|
||||||
|
VisibleScr: 1
|
||||||
|
VisibleRpt: 1
|
||||||
|
CountStat: 0
|
||||||
|
details:
|
||||||
|
DisciplineID: 2
|
||||||
|
DepartmentID: 2
|
||||||
|
FormulaCode: '{TBIL} - {DBIL}'
|
||||||
|
RefType: RANGE
|
||||||
|
Unit1: mg/dL
|
||||||
|
Decimal: 2
|
||||||
|
members:
|
||||||
|
- TestSiteID: 22
|
||||||
|
- TestSiteID: 23
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Test definition created
|
description: Test definition created
|
||||||
@ -4469,6 +4618,19 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
TestSiteId:
|
TestSiteId:
|
||||||
type: integer
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Validation error (e.g., invalid member TestSiteID)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
|
||||||
patch:
|
patch:
|
||||||
tags:
|
tags:
|
||||||
- Tests
|
- Tests
|
||||||
@ -4557,7 +4719,62 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
details:
|
details:
|
||||||
type: object
|
type: object
|
||||||
description: Type-specific details
|
description: |
|
||||||
|
Type-specific details. For CALC and GROUP types, include members array.
|
||||||
|
|
||||||
|
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||||
|
Invalid TestSiteIDs will result in a 400 error.
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
DepartmentID:
|
||||||
|
type: integer
|
||||||
|
ResultType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- NMRIC
|
||||||
|
- RANGE
|
||||||
|
- TEXT
|
||||||
|
- VSET
|
||||||
|
- NORES
|
||||||
|
RefType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- RANGE
|
||||||
|
- THOLD
|
||||||
|
- VSET
|
||||||
|
- TEXT
|
||||||
|
- NOREF
|
||||||
|
FormulaCode:
|
||||||
|
type: string
|
||||||
|
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||||
|
Unit1:
|
||||||
|
type: string
|
||||||
|
Factor:
|
||||||
|
type: number
|
||||||
|
Unit2:
|
||||||
|
type: string
|
||||||
|
Decimal:
|
||||||
|
type: integer
|
||||||
|
default: 2
|
||||||
|
Method:
|
||||||
|
type: string
|
||||||
|
ExpectedTAT:
|
||||||
|
type: integer
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Array of member tests for CALC and GROUP types.
|
||||||
|
Each member object must contain `TestSiteID` (the actual test ID).
|
||||||
|
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: The actual TestSiteID of the member test (required)
|
||||||
|
required:
|
||||||
|
- TestSiteID
|
||||||
refnum:
|
refnum:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -4590,6 +4807,19 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
TestSiteId:
|
TestSiteId:
|
||||||
type: integer
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Validation error (e.g., invalid member TestSiteID)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
|
||||||
/api/test/{id}:
|
/api/test/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -6262,7 +6492,10 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
testdefgrp:
|
testdefgrp:
|
||||||
type: array
|
type: array
|
||||||
description: Group members (for GROUP and CALC types)
|
description: |
|
||||||
|
Group members (for GROUP and CALC types).
|
||||||
|
When creating or updating, provide members in details.members array with TestSiteID field.
|
||||||
|
Do NOT use Member or SeqScr fields when creating/updating.
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -6274,10 +6507,9 @@ components:
|
|||||||
description: Parent group TestSiteID
|
description: Parent group TestSiteID
|
||||||
Member:
|
Member:
|
||||||
type: integer
|
type: integer
|
||||||
description: Member TestSiteID (foreign key to testdefsite)
|
description: |
|
||||||
MemberTestSiteID:
|
Member TestSiteID (foreign key to testdefsite).
|
||||||
type: integer
|
**Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
|
||||||
description: Member's actual TestSiteID (same as Member, for clarity)
|
|
||||||
TestSiteCode:
|
TestSiteCode:
|
||||||
type: string
|
type: string
|
||||||
description: Member test code
|
description: Member test code
|
||||||
@ -6623,6 +6855,72 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Soft delete timestamp
|
description: Soft delete timestamp
|
||||||
|
OrderTestList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
InternalOID:
|
||||||
|
type: integer
|
||||||
|
description: Internal order ID
|
||||||
|
OrderID:
|
||||||
|
type: string
|
||||||
|
description: Order ID (e.g., 0025030300001)
|
||||||
|
PlacerID:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
InternalPID:
|
||||||
|
type: integer
|
||||||
|
description: Patient internal ID
|
||||||
|
SiteID:
|
||||||
|
type: integer
|
||||||
|
PVADTID:
|
||||||
|
type: integer
|
||||||
|
description: Visit ADT ID
|
||||||
|
ReqApp:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
Priority:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- R
|
||||||
|
- S
|
||||||
|
- U
|
||||||
|
description: |
|
||||||
|
R: Routine
|
||||||
|
S: Stat
|
||||||
|
U: Urgent
|
||||||
|
PriorityLabel:
|
||||||
|
type: string
|
||||||
|
description: Priority display text
|
||||||
|
TrnDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Transaction/Order date
|
||||||
|
EffDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Effective date
|
||||||
|
CreateDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
OrderStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ORD
|
||||||
|
- SCH
|
||||||
|
- ANA
|
||||||
|
- VER
|
||||||
|
- REV
|
||||||
|
- REP
|
||||||
|
description: |
|
||||||
|
ORD: Ordered
|
||||||
|
SCH: Scheduled
|
||||||
|
ANA: Analysis
|
||||||
|
VER: Verified
|
||||||
|
REV: Reviewed
|
||||||
|
REP: Reported
|
||||||
|
OrderStatusLabel:
|
||||||
|
type: string
|
||||||
|
description: Order status display text
|
||||||
OrderTest:
|
OrderTest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -7071,31 +7369,28 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
RuleID:
|
RuleID:
|
||||||
type: integer
|
type: integer
|
||||||
Name:
|
RuleCode:
|
||||||
type: string
|
type: string
|
||||||
|
example: AUTO_SET_RESULT
|
||||||
|
RuleName:
|
||||||
|
type: string
|
||||||
|
example: Automatically Set Result
|
||||||
Description:
|
Description:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
ScopeType:
|
example: ORDER_CREATED
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- GLOBAL
|
|
||||||
- TESTSITE
|
|
||||||
TestSiteID:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
Priority:
|
description: Raw DSL expression (editable)
|
||||||
type: integer
|
example: 'if(sex(''F'') ? set_result(0.7) : set_result(1))'
|
||||||
Active:
|
ConditionExprCompiled:
|
||||||
type: integer
|
type: string
|
||||||
enum:
|
nullable: true
|
||||||
- 0
|
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
||||||
- 1
|
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -7115,8 +7410,6 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
RuleID:
|
RuleID:
|
||||||
type: integer
|
type: integer
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
example: SET_RESULT
|
example: SET_RESULT
|
||||||
@ -7124,6 +7417,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: JSON string parameters
|
description: JSON string parameters
|
||||||
nullable: true
|
nullable: true
|
||||||
|
example: '{"testSiteID": 1, "value": "Normal"}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -7132,7 +7426,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
RuleWithActions:
|
RuleWithDetails:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/RuleDef'
|
- $ref: '#/components/schemas/RuleDef'
|
||||||
- type: object
|
- type: object
|
||||||
@ -7141,6 +7435,29 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/RuleAction'
|
$ref: '#/components/schemas/RuleAction'
|
||||||
|
linkedTests:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Array of TestSiteIDs this rule is linked to. Rules are active only when attached to tests.
|
||||||
|
TestRule:
|
||||||
|
type: object
|
||||||
|
description: Mapping between a rule and a test site (testrule table). Rules are active when linked via this table.
|
||||||
|
properties:
|
||||||
|
TestRuleID:
|
||||||
|
type: integer
|
||||||
|
RuleID:
|
||||||
|
type: integer
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
CreateDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
EndDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
Contact:
|
Contact:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -7263,16 +7580,54 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Test name
|
description: Test name
|
||||||
nullable: true
|
nullable: true
|
||||||
|
TestType:
|
||||||
|
type: string
|
||||||
|
description: Test type code identifying the test category
|
||||||
|
enum:
|
||||||
|
- TEST
|
||||||
|
- PARAM
|
||||||
|
- CALC
|
||||||
|
- GROUP
|
||||||
|
- TITLE
|
||||||
SID:
|
SID:
|
||||||
type: string
|
type: string
|
||||||
description: Order ID reference
|
description: Order ID reference
|
||||||
SampleID:
|
SampleID:
|
||||||
type: string
|
type: string
|
||||||
description: Sample ID (same as OrderID)
|
description: Sample ID (same as OrderID)
|
||||||
|
SeqScr:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Sequence number for this test on the screen
|
||||||
|
SeqRpt:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Sequence number for this test in reports
|
||||||
Result:
|
Result:
|
||||||
type: string
|
type: string
|
||||||
description: Test result value
|
description: Test result value
|
||||||
nullable: true
|
nullable: true
|
||||||
|
Discipline:
|
||||||
|
type: object
|
||||||
|
description: Discipline metadata used for ordering tests
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
DisciplineCode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
DisciplineName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
SeqScr:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Discipline sequence on the screen
|
||||||
|
SeqRpt:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Discipline sequence in reports
|
||||||
ResultDateTime:
|
ResultDateTime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@ -35,6 +35,8 @@ tags:
|
|||||||
description: Specimen and container management
|
description: Specimen and container management
|
||||||
- name: Tests
|
- name: Tests
|
||||||
description: Test definitions and test catalog
|
description: Test definitions and test catalog
|
||||||
|
- name: Calculations
|
||||||
|
description: Lightweight calculator endpoint for retrieving computed values by code or name
|
||||||
- name: Orders
|
- name: Orders
|
||||||
description: Laboratory order management
|
description: Laboratory order management
|
||||||
- name: Results
|
- name: Results
|
||||||
@ -56,7 +58,7 @@ tags:
|
|||||||
- name: Users
|
- name: Users
|
||||||
description: User management and administration
|
description: User management and administration
|
||||||
- name: Rules
|
- name: Rules
|
||||||
description: Common rule engine (events, conditions, actions)
|
description: Rule engine - rules can be linked to multiple tests via testrule mapping table
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@ -145,6 +147,8 @@ components:
|
|||||||
$ref: './components/schemas/tests.yaml#/TestMap'
|
$ref: './components/schemas/tests.yaml#/TestMap'
|
||||||
|
|
||||||
# Orders schemas
|
# Orders schemas
|
||||||
|
OrderTestList:
|
||||||
|
$ref: './components/schemas/orders.yaml#/OrderTestList'
|
||||||
OrderTest:
|
OrderTest:
|
||||||
$ref: './components/schemas/orders.yaml#/OrderTest'
|
$ref: './components/schemas/orders.yaml#/OrderTest'
|
||||||
OrderItem:
|
OrderItem:
|
||||||
@ -189,8 +193,10 @@ components:
|
|||||||
$ref: './components/schemas/rules.yaml#/RuleDef'
|
$ref: './components/schemas/rules.yaml#/RuleDef'
|
||||||
RuleAction:
|
RuleAction:
|
||||||
$ref: './components/schemas/rules.yaml#/RuleAction'
|
$ref: './components/schemas/rules.yaml#/RuleAction'
|
||||||
RuleWithActions:
|
RuleWithDetails:
|
||||||
$ref: './components/schemas/rules.yaml#/RuleWithActions'
|
$ref: './components/schemas/rules.yaml#/RuleWithDetails'
|
||||||
|
TestRule:
|
||||||
|
$ref: './components/schemas/rules.yaml#/TestRule' # Mapping table between rules and tests
|
||||||
|
|
||||||
# Paths are in separate files in the paths/ directory
|
# Paths are in separate files in the paths/ directory
|
||||||
# To view the complete API with all paths, use: api-docs.bundled.yaml
|
# To view the complete API with all paths, use: api-docs.bundled.yaml
|
||||||
|
|||||||
@ -1,3 +1,61 @@
|
|||||||
|
OrderTestList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
InternalOID:
|
||||||
|
type: integer
|
||||||
|
description: Internal order ID
|
||||||
|
OrderID:
|
||||||
|
type: string
|
||||||
|
description: Order ID (e.g., 0025030300001)
|
||||||
|
PlacerID:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
InternalPID:
|
||||||
|
type: integer
|
||||||
|
description: Patient internal ID
|
||||||
|
SiteID:
|
||||||
|
type: integer
|
||||||
|
PVADTID:
|
||||||
|
type: integer
|
||||||
|
description: Visit ADT ID
|
||||||
|
ReqApp:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
Priority:
|
||||||
|
type: string
|
||||||
|
enum: [R, S, U]
|
||||||
|
description: |
|
||||||
|
R: Routine
|
||||||
|
S: Stat
|
||||||
|
U: Urgent
|
||||||
|
PriorityLabel:
|
||||||
|
type: string
|
||||||
|
description: Priority display text
|
||||||
|
TrnDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Transaction/Order date
|
||||||
|
EffDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Effective date
|
||||||
|
CreateDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
OrderStatus:
|
||||||
|
type: string
|
||||||
|
enum: [ORD, SCH, ANA, VER, REV, REP]
|
||||||
|
description: |
|
||||||
|
ORD: Ordered
|
||||||
|
SCH: Scheduled
|
||||||
|
ANA: Analysis
|
||||||
|
VER: Verified
|
||||||
|
REV: Reviewed
|
||||||
|
REP: Reported
|
||||||
|
OrderStatusLabel:
|
||||||
|
type: string
|
||||||
|
description: Order status display text
|
||||||
|
|
||||||
OrderTest:
|
OrderTest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -132,16 +190,49 @@ OrderTestItem:
|
|||||||
type: string
|
type: string
|
||||||
description: Test name
|
description: Test name
|
||||||
nullable: true
|
nullable: true
|
||||||
|
TestType:
|
||||||
|
type: string
|
||||||
|
description: Test type code identifying the test category
|
||||||
|
enum: [TEST, PARAM, CALC, GROUP, TITLE]
|
||||||
SID:
|
SID:
|
||||||
type: string
|
type: string
|
||||||
description: Order ID reference
|
description: Order ID reference
|
||||||
SampleID:
|
SampleID:
|
||||||
type: string
|
type: string
|
||||||
description: Sample ID (same as OrderID)
|
description: Sample ID (same as OrderID)
|
||||||
|
SeqScr:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Sequence number for this test on the screen
|
||||||
|
SeqRpt:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Sequence number for this test in reports
|
||||||
Result:
|
Result:
|
||||||
type: string
|
type: string
|
||||||
description: Test result value
|
description: Test result value
|
||||||
nullable: true
|
nullable: true
|
||||||
|
Discipline:
|
||||||
|
type: object
|
||||||
|
description: Discipline metadata used for ordering tests
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
DisciplineCode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
DisciplineName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
SeqScr:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Discipline sequence on the screen
|
||||||
|
SeqRpt:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: Discipline sequence in reports
|
||||||
ResultDateTime:
|
ResultDateTime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@ -3,27 +3,28 @@ RuleDef:
|
|||||||
properties:
|
properties:
|
||||||
RuleID:
|
RuleID:
|
||||||
type: integer
|
type: integer
|
||||||
Name:
|
RuleCode:
|
||||||
type: string
|
type: string
|
||||||
|
example: AUTO_SET_RESULT
|
||||||
|
RuleName:
|
||||||
|
type: string
|
||||||
|
example: Automatically Set Result
|
||||||
Description:
|
Description:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
ScopeType:
|
example: ORDER_CREATED
|
||||||
type: string
|
|
||||||
enum: [GLOBAL, TESTSITE]
|
|
||||||
TestSiteID:
|
|
||||||
type: integer
|
|
||||||
nullable: true
|
|
||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
Priority:
|
description: Raw DSL expression (editable)
|
||||||
type: integer
|
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
|
||||||
Active:
|
ConditionExprCompiled:
|
||||||
type: integer
|
type: string
|
||||||
enum: [0, 1]
|
nullable: true
|
||||||
|
description: Compiled JSON structure (auto-generated from ConditionExpr)
|
||||||
|
example: '{"conditionExpr":"order["Sex"] == \"F\"","valueExpr":"(order["Sex"] == \"F\") ? 0.7 : 1","then":{"type":"SET_RESULT","value":0.7,"valueExpr":"0.7"},"else":{"type":"SET_RESULT","value":1,"valueExpr":"1"}}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -44,8 +45,6 @@ RuleAction:
|
|||||||
type: integer
|
type: integer
|
||||||
RuleID:
|
RuleID:
|
||||||
type: integer
|
type: integer
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
example: SET_RESULT
|
example: SET_RESULT
|
||||||
@ -53,6 +52,7 @@ RuleAction:
|
|||||||
type: string
|
type: string
|
||||||
description: JSON string parameters
|
description: JSON string parameters
|
||||||
nullable: true
|
nullable: true
|
||||||
|
example: '{"testSiteID": 1, "value": "Normal"}'
|
||||||
CreateDate:
|
CreateDate:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@ -62,7 +62,7 @@ RuleAction:
|
|||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
|
|
||||||
RuleWithActions:
|
RuleWithDetails:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: './rules.yaml#/RuleDef'
|
- $ref: './rules.yaml#/RuleDef'
|
||||||
- type: object
|
- type: object
|
||||||
@ -71,3 +71,27 @@ RuleWithActions:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: './rules.yaml#/RuleAction'
|
$ref: './rules.yaml#/RuleAction'
|
||||||
|
linkedTests:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Array of TestSiteIDs this rule is linked to. Rules are active only when attached to tests.
|
||||||
|
|
||||||
|
TestRule:
|
||||||
|
type: object
|
||||||
|
description: Mapping between a rule and a test site (testrule table). Rules are active when linked via this table.
|
||||||
|
properties:
|
||||||
|
TestRuleID:
|
||||||
|
type: integer
|
||||||
|
RuleID:
|
||||||
|
type: integer
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
CreateDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
EndDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
|||||||
@ -141,7 +141,10 @@ TestDefinition:
|
|||||||
type: object
|
type: object
|
||||||
testdefgrp:
|
testdefgrp:
|
||||||
type: array
|
type: array
|
||||||
description: Group members (for GROUP and CALC types)
|
description: |
|
||||||
|
Group members (for GROUP and CALC types).
|
||||||
|
When creating or updating, provide members in details.members array with TestSiteID field.
|
||||||
|
Do NOT use Member or SeqScr fields when creating/updating.
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -153,10 +156,9 @@ TestDefinition:
|
|||||||
description: Parent group TestSiteID
|
description: Parent group TestSiteID
|
||||||
Member:
|
Member:
|
||||||
type: integer
|
type: integer
|
||||||
description: Member TestSiteID (foreign key to testdefsite)
|
description: |
|
||||||
MemberTestSiteID:
|
Member TestSiteID (foreign key to testdefsite).
|
||||||
type: integer
|
**Note**: This field is in the response. When creating/updating, use TestSiteID in details.members array instead.
|
||||||
description: Member's actual TestSiteID (same as Member, for clarity)
|
|
||||||
TestSiteCode:
|
TestSiteCode:
|
||||||
type: string
|
type: string
|
||||||
description: Member test code
|
description: Member test code
|
||||||
|
|||||||
37
public/paths/calc.yaml
Normal file
37
public/paths/calc.yaml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/api/calc/{codeOrName}:
|
||||||
|
post:
|
||||||
|
tags: [Calculations]
|
||||||
|
summary: Evaluate a configured calculation by test code or name and return the numeric result only.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: codeOrName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: TestSiteCode or TestSiteName of the calculated test (case-insensitive).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Key-value pairs where keys match member tests used in the formula.
|
||||||
|
additionalProperties:
|
||||||
|
type: number
|
||||||
|
example:
|
||||||
|
TBIL: 5
|
||||||
|
DBIL: 3
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
value:
|
||||||
|
IBIL: 2.0
|
||||||
|
incomplete:
|
||||||
|
value: {}
|
||||||
@ -30,12 +30,6 @@
|
|||||||
VER: Verified
|
VER: Verified
|
||||||
REV: Reviewed
|
REV: Reviewed
|
||||||
REP: Reported
|
REP: Reported
|
||||||
- name: include
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [details]
|
|
||||||
description: Include specimens and tests in response
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of orders
|
description: List of orders
|
||||||
@ -51,7 +45,7 @@
|
|||||||
data:
|
data:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '../components/schemas/orders.yaml#/OrderTest'
|
$ref: '../components/schemas/orders.yaml#/OrderTestList'
|
||||||
|
|
||||||
post:
|
post:
|
||||||
tags: [Orders]
|
tags: [Orders]
|
||||||
|
|||||||
@ -10,28 +10,16 @@
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Filter by event code
|
description: Filter by event code
|
||||||
- name: Active
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
enum: [0, 1]
|
|
||||||
description: Filter by active flag
|
|
||||||
- name: ScopeType
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [GLOBAL, TESTSITE]
|
|
||||||
description: Filter by scope type
|
|
||||||
- name: TestSiteID
|
- name: TestSiteID
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: Filter by TestSiteID (for TESTSITE scope)
|
description: Filter by TestSiteID (returns rules linked to this test). Rules are only returned when attached to tests.
|
||||||
- name: search
|
- name: search
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Search by rule name
|
description: Search by rule code or name
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of rules
|
description: List of rules
|
||||||
@ -52,6 +40,9 @@
|
|||||||
post:
|
post:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Create rule
|
summary: Create rule
|
||||||
|
description: |
|
||||||
|
Create a new rule. Rules must be linked to at least one test via TestSiteIDs.
|
||||||
|
A single rule can be linked to multiple tests. Rules are active only when attached to tests.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@ -61,41 +52,40 @@
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Name:
|
RuleCode:
|
||||||
type: string
|
type: string
|
||||||
|
example: AUTO_SET_RESULT
|
||||||
|
RuleName:
|
||||||
|
type: string
|
||||||
|
example: Automatically Set Result
|
||||||
Description:
|
Description:
|
||||||
type: string
|
type: string
|
||||||
EventCode:
|
EventCode:
|
||||||
type: string
|
type: string
|
||||||
example: ORDER_CREATED
|
example: ORDER_CREATED
|
||||||
ScopeType:
|
TestSiteIDs:
|
||||||
type: string
|
type: array
|
||||||
enum: [GLOBAL, TESTSITE]
|
items:
|
||||||
TestSiteID:
|
type: integer
|
||||||
type: integer
|
description: Array of TestSiteIDs to link this rule to (required)
|
||||||
nullable: true
|
example: [1, 2, 3]
|
||||||
ConditionExpr:
|
ConditionExpr:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
Priority:
|
example: 'order["Priority"] == "S"'
|
||||||
type: integer
|
|
||||||
Active:
|
|
||||||
type: integer
|
|
||||||
enum: [0, 1]
|
|
||||||
actions:
|
actions:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
|
example: SET_RESULT
|
||||||
ActionParams:
|
ActionParams:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: object
|
- type: object
|
||||||
required: [Name, EventCode, ScopeType]
|
required: [RuleCode, RuleName, EventCode, TestSiteIDs]
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Rule created
|
description: Rule created
|
||||||
@ -103,7 +93,7 @@
|
|||||||
/api/rules/{id}:
|
/api/rules/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Get rule (with actions)
|
summary: Get rule with actions and linked tests
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -112,9 +102,10 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule details
|
description: Rule details with actions and linked test sites
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -125,13 +116,17 @@
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
data:
|
data:
|
||||||
$ref: '../components/schemas/rules.yaml#/RuleWithActions'
|
$ref: '../components/schemas/rules.yaml#/RuleWithDetails'
|
||||||
'404':
|
'404':
|
||||||
description: Rule not found
|
description: Rule not found
|
||||||
|
|
||||||
patch:
|
patch:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
summary: Update rule
|
summary: Update rule
|
||||||
|
description: |
|
||||||
|
Update a rule. TestSiteIDs can be provided to update which tests the rule is linked to.
|
||||||
|
Tests not in the new list will be unlinked, and new tests will be linked.
|
||||||
|
Rules are active only when attached to tests.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -140,6 +135,7 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -147,14 +143,16 @@
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Name: { type: string }
|
RuleCode: { type: string }
|
||||||
|
RuleName: { type: string }
|
||||||
Description: { type: string }
|
Description: { type: string }
|
||||||
EventCode: { type: string }
|
EventCode: { type: string }
|
||||||
ScopeType: { type: string, enum: [GLOBAL, TESTSITE] }
|
TestSiteIDs:
|
||||||
TestSiteID: { type: integer, nullable: true }
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Array of TestSiteIDs to link this rule to
|
||||||
ConditionExpr: { type: string, nullable: true }
|
ConditionExpr: { type: string, nullable: true }
|
||||||
Priority: { type: integer }
|
|
||||||
Active: { type: integer, enum: [0, 1] }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule updated
|
description: Rule updated
|
||||||
@ -172,6 +170,7 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule deleted
|
description: Rule deleted
|
||||||
@ -201,6 +200,54 @@
|
|||||||
'200':
|
'200':
|
||||||
description: Validation result
|
description: Validation result
|
||||||
|
|
||||||
|
/api/rules/compile:
|
||||||
|
post:
|
||||||
|
tags: [Rules]
|
||||||
|
summary: Compile DSL expression to engine-compatible structure
|
||||||
|
description: |
|
||||||
|
Compile a DSL expression to the engine-compatible JSON structure.
|
||||||
|
Frontend calls this when user clicks "Compile" button.
|
||||||
|
Returns compiled structure that can be saved to ConditionExprCompiled field.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
expr:
|
||||||
|
type: string
|
||||||
|
description: Raw DSL expression
|
||||||
|
example: "if(sex('F') ? set_result(0.7) : set_result(1))"
|
||||||
|
required: [expr]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Compilation successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
raw:
|
||||||
|
type: string
|
||||||
|
description: Original DSL expression
|
||||||
|
compiled:
|
||||||
|
type: object
|
||||||
|
description: Parsed structure with conditionExpr, valueExpr, then, else
|
||||||
|
conditionExprCompiled:
|
||||||
|
type: string
|
||||||
|
description: JSON string to save to ConditionExprCompiled field
|
||||||
|
'400':
|
||||||
|
description: Compilation failed (invalid syntax)
|
||||||
|
|
||||||
/api/rules/{id}/actions:
|
/api/rules/{id}/actions:
|
||||||
get:
|
get:
|
||||||
tags: [Rules]
|
tags: [Rules]
|
||||||
@ -213,6 +260,7 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Actions list
|
description: Actions list
|
||||||
@ -241,6 +289,7 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -248,10 +297,9 @@
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq:
|
|
||||||
type: integer
|
|
||||||
ActionType:
|
ActionType:
|
||||||
type: string
|
type: string
|
||||||
|
example: SET_RESULT
|
||||||
ActionParams:
|
ActionParams:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
@ -273,11 +321,13 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
- name: actionId
|
- name: actionId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleActionID
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -285,7 +335,6 @@
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
Seq: { type: integer }
|
|
||||||
ActionType: { type: string }
|
ActionType: { type: string }
|
||||||
ActionParams:
|
ActionParams:
|
||||||
oneOf:
|
oneOf:
|
||||||
@ -306,11 +355,13 @@
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleID
|
||||||
- name: actionId
|
- name: actionId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
description: RuleActionID
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Action deleted
|
description: Action deleted
|
||||||
|
|||||||
@ -141,7 +141,52 @@
|
|||||||
type: integer
|
type: integer
|
||||||
details:
|
details:
|
||||||
type: object
|
type: object
|
||||||
description: Type-specific details
|
description: |
|
||||||
|
Type-specific details. For CALC and GROUP types, include members array.
|
||||||
|
|
||||||
|
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||||
|
Invalid TestSiteIDs will result in a 400 error.
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
DepartmentID:
|
||||||
|
type: integer
|
||||||
|
ResultType:
|
||||||
|
type: string
|
||||||
|
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
|
||||||
|
RefType:
|
||||||
|
type: string
|
||||||
|
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
|
||||||
|
FormulaCode:
|
||||||
|
type: string
|
||||||
|
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||||
|
Unit1:
|
||||||
|
type: string
|
||||||
|
Factor:
|
||||||
|
type: number
|
||||||
|
Unit2:
|
||||||
|
type: string
|
||||||
|
Decimal:
|
||||||
|
type: integer
|
||||||
|
default: 2
|
||||||
|
Method:
|
||||||
|
type: string
|
||||||
|
ExpectedTAT:
|
||||||
|
type: integer
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Array of member tests for CALC and GROUP types.
|
||||||
|
Each member object must contain `TestSiteID` (the actual test ID).
|
||||||
|
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: The actual TestSiteID of the member test (required)
|
||||||
|
required:
|
||||||
|
- TestSiteID
|
||||||
refnum:
|
refnum:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -159,6 +204,30 @@
|
|||||||
- TestSiteCode
|
- TestSiteCode
|
||||||
- TestSiteName
|
- TestSiteName
|
||||||
- TestType
|
- TestType
|
||||||
|
examples:
|
||||||
|
CALC_test:
|
||||||
|
summary: Create calculated test with members
|
||||||
|
value:
|
||||||
|
SiteID: 1
|
||||||
|
TestSiteCode: IBIL
|
||||||
|
TestSiteName: Indirect Bilirubin
|
||||||
|
TestType: CALC
|
||||||
|
Description: Bilirubin Indirek
|
||||||
|
SeqScr: 210
|
||||||
|
SeqRpt: 210
|
||||||
|
VisibleScr: 1
|
||||||
|
VisibleRpt: 1
|
||||||
|
CountStat: 0
|
||||||
|
details:
|
||||||
|
DisciplineID: 2
|
||||||
|
DepartmentID: 2
|
||||||
|
FormulaCode: "{TBIL} - {DBIL}"
|
||||||
|
RefType: RANGE
|
||||||
|
Unit1: mg/dL
|
||||||
|
Decimal: 2
|
||||||
|
members:
|
||||||
|
- TestSiteID: 22
|
||||||
|
- TestSiteID: 23
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Test definition created
|
description: Test definition created
|
||||||
@ -177,6 +246,19 @@
|
|||||||
properties:
|
properties:
|
||||||
TestSiteId:
|
TestSiteId:
|
||||||
type: integer
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Validation error (e.g., invalid member TestSiteID)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
|
||||||
|
|
||||||
patch:
|
patch:
|
||||||
tags: [Tests]
|
tags: [Tests]
|
||||||
@ -250,7 +332,52 @@
|
|||||||
type: integer
|
type: integer
|
||||||
details:
|
details:
|
||||||
type: object
|
type: object
|
||||||
description: Type-specific details
|
description: |
|
||||||
|
Type-specific details. For CALC and GROUP types, include members array.
|
||||||
|
|
||||||
|
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||||
|
Invalid TestSiteIDs will result in a 400 error.
|
||||||
|
properties:
|
||||||
|
DisciplineID:
|
||||||
|
type: integer
|
||||||
|
DepartmentID:
|
||||||
|
type: integer
|
||||||
|
ResultType:
|
||||||
|
type: string
|
||||||
|
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
|
||||||
|
RefType:
|
||||||
|
type: string
|
||||||
|
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
|
||||||
|
FormulaCode:
|
||||||
|
type: string
|
||||||
|
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||||
|
Unit1:
|
||||||
|
type: string
|
||||||
|
Factor:
|
||||||
|
type: number
|
||||||
|
Unit2:
|
||||||
|
type: string
|
||||||
|
Decimal:
|
||||||
|
type: integer
|
||||||
|
default: 2
|
||||||
|
Method:
|
||||||
|
type: string
|
||||||
|
ExpectedTAT:
|
||||||
|
type: integer
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Array of member tests for CALC and GROUP types.
|
||||||
|
Each member object must contain `TestSiteID` (the actual test ID).
|
||||||
|
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: The actual TestSiteID of the member test (required)
|
||||||
|
required:
|
||||||
|
- TestSiteID
|
||||||
refnum:
|
refnum:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -283,6 +410,19 @@
|
|||||||
properties:
|
properties:
|
||||||
TestSiteId:
|
TestSiteId:
|
||||||
type: integer
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Validation error (e.g., invalid member TestSiteID)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Invalid member TestSiteID(s): 185, 186. Make sure to use TestSiteID, not SeqScr or other values.'
|
||||||
|
|
||||||
/api/test/{id}:
|
/api/test/{id}:
|
||||||
get:
|
get:
|
||||||
|
|||||||
148
tests/feature/Calculator/CalculatorEndpointTest.php
Normal file
148
tests/feature/Calculator/CalculatorEndpointTest.php
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Calculator;
|
||||||
|
|
||||||
|
use App\Models\Test\TestDefCalModel;
|
||||||
|
use App\Models\Test\TestDefSiteModel;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\FeatureTestTrait;
|
||||||
|
|
||||||
|
class CalculatorEndpointTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use FeatureTestTrait;
|
||||||
|
|
||||||
|
protected TestDefSiteModel $siteModel;
|
||||||
|
protected TestDefCalModel $calcModel;
|
||||||
|
protected ?int $siteId = null;
|
||||||
|
protected ?int $calcId = null;
|
||||||
|
protected string $calcName;
|
||||||
|
protected string $calcCode;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->siteModel = new TestDefSiteModel();
|
||||||
|
$this->calcModel = new TestDefCalModel();
|
||||||
|
$this->calcName = 'API Calc ' . uniqid();
|
||||||
|
$this->calcCode = $this->generateUniqueCalcCode();
|
||||||
|
|
||||||
|
$siteId = $this->siteModel->insert([
|
||||||
|
'SiteID' => 1,
|
||||||
|
'TestSiteCode' => $this->calcCode,
|
||||||
|
'TestSiteName' => $this->calcName,
|
||||||
|
'TestType' => 'CALC',
|
||||||
|
'ResultType' => 'NMRIC',
|
||||||
|
'RefType' => 'RANGE',
|
||||||
|
'Unit1' => 'mg/dL',
|
||||||
|
'VisibleScr' => 1,
|
||||||
|
'VisibleRpt' => 1,
|
||||||
|
'CountStat' => 0,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
'StartDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotFalse($siteId, 'Failed to insert testdefsite');
|
||||||
|
$this->siteId = $siteId;
|
||||||
|
|
||||||
|
$this->calcId = $this->calcModel->insert([
|
||||||
|
'TestSiteID' => $siteId,
|
||||||
|
'DisciplineID' => 1,
|
||||||
|
'DepartmentID' => 1,
|
||||||
|
'FormulaCode' => 'TBIL - DBIL',
|
||||||
|
'RefType' => 'RANGE',
|
||||||
|
'Unit1' => 'mg/dL',
|
||||||
|
'Factor' => 1,
|
||||||
|
'Decimal' => 2,
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotFalse($this->calcId, 'Failed to insert testdefcal');
|
||||||
|
|
||||||
|
$this->assertNotNull($this->calcModel->findActiveByCodeOrName($this->calcCode));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->calcId) {
|
||||||
|
$this->calcModel->delete($this->calcId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->siteId) {
|
||||||
|
$this->siteModel->delete($this->siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateByCodeReturnsValue()
|
||||||
|
{
|
||||||
|
$response = $this->postCalc($this->calcCode, ['TBIL' => 5, 'DBIL' => 3]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$data = $this->decodeResponse($response);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey($this->calcCode, $data);
|
||||||
|
$this->assertEquals(2.0, $data[$this->calcCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCalculateByNameReturnsValue()
|
||||||
|
{
|
||||||
|
$response = $this->postCalc($this->calcName, ['TBIL' => 4, 'DBIL' => 1]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$data = $this->decodeResponse($response);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey($this->calcCode, $data);
|
||||||
|
$this->assertEquals(3.0, $data[$this->calcCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIncompletePayloadReturnsEmptyObject()
|
||||||
|
{
|
||||||
|
$response = $this->postCalc($this->calcCode, ['TBIL' => 5]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$data = $this->decodeResponse($response);
|
||||||
|
$this->assertSame([], $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownCalculatorReturnsEmptyObject()
|
||||||
|
{
|
||||||
|
$response = $this->postCalc('UNKNOWN_CALC', ['TBIL' => 3, 'DBIL' => 1]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$data = $this->decodeResponse($response);
|
||||||
|
$this->assertSame([], $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function postCalc(string $identifier, array $payload)
|
||||||
|
{
|
||||||
|
return $this->withHeaders(['Content-Type' => 'application/json'])
|
||||||
|
->withBody(json_encode($payload))
|
||||||
|
->call('post', 'api/calc/' . rawurlencode($identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeResponse($response): array
|
||||||
|
{
|
||||||
|
$json = $response->getJSON();
|
||||||
|
if (empty($json)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode($json, true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUniqueCalcCode(): string
|
||||||
|
{
|
||||||
|
$tries = 0;
|
||||||
|
do {
|
||||||
|
$code = 'TC' . strtoupper(bin2hex(random_bytes(2)));
|
||||||
|
$exists = $this->siteModel->where('TestSiteCode', $code)
|
||||||
|
->where('EndDate IS NULL')
|
||||||
|
->first();
|
||||||
|
} while ($exists && ++$tries < 20);
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,66 +14,7 @@ class OrderCreateTest extends CIUnitTestCase
|
|||||||
|
|
||||||
public function testCreateOrderSuccess()
|
public function testCreateOrderSuccess()
|
||||||
{
|
{
|
||||||
$faker = Factory::create('id_ID');
|
$internalPID = $this->createOrderTestPatient();
|
||||||
|
|
||||||
// First create a patient using the same approach as PatientCreateTest
|
|
||||||
$patientPayload = [
|
|
||||||
"PatientID" => "ORD" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
|
|
||||||
"AlternatePID" => "DMY" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
|
|
||||||
"Prefix" => $faker->title,
|
|
||||||
"NameFirst" => "Order",
|
|
||||||
"NameMiddle" => $faker->firstName,
|
|
||||||
"NameMaiden" => $faker->firstName,
|
|
||||||
"NameLast" => "Test",
|
|
||||||
"Suffix" => "S.Kom",
|
|
||||||
"NameAlias" => $faker->userName,
|
|
||||||
"Sex" => $faker->numberBetween(5, 6),
|
|
||||||
"PlaceOfBirth" => $faker->city,
|
|
||||||
"Birthdate" => "1990-01-01",
|
|
||||||
"ZIP" => $faker->postcode,
|
|
||||||
"Street_1" => $faker->streetAddress,
|
|
||||||
"Street_2" => "RT " . $faker->numberBetween(1, 10) . " RW " . $faker->numberBetween(1, 10),
|
|
||||||
"Street_3" => "Blok " . $faker->numberBetween(1, 20),
|
|
||||||
"City" => $faker->city,
|
|
||||||
"Province" => $faker->state,
|
|
||||||
"EmailAddress1" => "A" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
|
|
||||||
"EmailAddress2" => "B" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
|
|
||||||
"Phone" => $faker->numerify('08##########'),
|
|
||||||
"MobilePhone" => $faker->numerify('08##########'),
|
|
||||||
"Race" => (string) $faker->numberBetween(175, 205),
|
|
||||||
"Country" => (string) $faker->numberBetween(221, 469),
|
|
||||||
"MaritalStatus" => (string) $faker->numberBetween(8, 15),
|
|
||||||
"Religion" => (string) $faker->numberBetween(206, 212),
|
|
||||||
"Ethnic" => (string) $faker->numberBetween(213, 220),
|
|
||||||
"Citizenship" => "WNI",
|
|
||||||
"DeathIndicator" => (string) $faker->numberBetween(16, 17),
|
|
||||||
"LinkTo" => (string) $faker->numberBetween(2, 3),
|
|
||||||
"Custodian" => 1,
|
|
||||||
"PatIdt" => [
|
|
||||||
"IdentifierType" => "KTP",
|
|
||||||
"Identifier" => $faker->nik() ?? $faker->numerify('################')
|
|
||||||
],
|
|
||||||
"PatAtt" => [
|
|
||||||
[ "Address" => "/api/upload/" . $faker->uuid . ".jpg" ]
|
|
||||||
],
|
|
||||||
"PatCom" => $faker->sentence,
|
|
||||||
];
|
|
||||||
|
|
||||||
if($patientPayload['DeathIndicator'] == '16') {
|
|
||||||
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
|
|
||||||
} else {
|
|
||||||
$patientPayload['DeathDateTime'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$patientResult = $this->withBodyFormat('json')->call('post', 'api/patient', $patientPayload);
|
|
||||||
|
|
||||||
// Check patient creation succeeded
|
|
||||||
$patientResult->assertStatus(201);
|
|
||||||
|
|
||||||
$patientBody = json_decode($patientResult->getBody(), true);
|
|
||||||
$internalPID = $patientBody['data']['InternalPID'] ?? null;
|
|
||||||
|
|
||||||
$this->assertNotNull($internalPID, 'Failed to create test patient. Response: ' . print_r($patientBody, true));
|
|
||||||
|
|
||||||
// Get available tests from testdefsite
|
// Get available tests from testdefsite
|
||||||
$testsResult = $this->call('get', 'api/test');
|
$testsResult = $this->call('get', 'api/test');
|
||||||
@ -127,9 +68,11 @@ class OrderCreateTest extends CIUnitTestCase
|
|||||||
|
|
||||||
$result->assertStatus(400);
|
$result->assertStatus(400);
|
||||||
|
|
||||||
$body = json_decode($result->getBody(), true);
|
$body = json_decode(strip_tags($result->getBody()), true);
|
||||||
$this->assertIsArray($body);
|
$this->assertIsArray($body);
|
||||||
$this->assertArrayHasKey('errors', $body);
|
$messages = $body['messages'] ?? $body['errors'] ?? [];
|
||||||
|
$this->assertIsArray($messages);
|
||||||
|
$this->assertArrayHasKey('InternalPID', $messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateOrderFailsWithInvalidPatient()
|
public function testCreateOrderFailsWithInvalidPatient()
|
||||||
@ -145,34 +88,16 @@ class OrderCreateTest extends CIUnitTestCase
|
|||||||
|
|
||||||
$result->assertStatus(400);
|
$result->assertStatus(400);
|
||||||
|
|
||||||
$body = json_decode($result->getBody(), true);
|
$body = json_decode(strip_tags($result->getBody()), true);
|
||||||
$this->assertIsArray($body);
|
$this->assertIsArray($body);
|
||||||
$this->assertArrayHasKey('errors', $body);
|
$messages = $body['messages'] ?? $body['errors'] ?? [];
|
||||||
|
$this->assertIsArray($messages);
|
||||||
|
$this->assertArrayHasKey('InternalPID', $messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateOrderWithMultipleTests()
|
public function testCreateOrderWithMultipleTests()
|
||||||
{
|
{
|
||||||
$faker = Factory::create('id_ID');
|
$internalPID = $this->createOrderTestPatient();
|
||||||
|
|
||||||
// First create a patient
|
|
||||||
$patientPayload = [
|
|
||||||
"PatientID" => "ORDM" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000),
|
|
||||||
"NameFirst" => "Multi",
|
|
||||||
"NameLast" => "Test",
|
|
||||||
"Sex" => "2",
|
|
||||||
"Birthdate" => "1985-05-15",
|
|
||||||
"PatIdt" => [
|
|
||||||
"IdentifierType" => "KTP",
|
|
||||||
"Identifier" => $faker->numerify('################')
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
$patientResult = $this->withBodyFormat('json')->call('post', 'api/patient', $patientPayload);
|
|
||||||
|
|
||||||
$patientBody = json_decode($patientResult->getBody(), true);
|
|
||||||
$internalPID = $patientBody['data']['InternalPID'] ?? null;
|
|
||||||
|
|
||||||
$this->assertNotNull($internalPID, 'Failed to create test patient');
|
|
||||||
|
|
||||||
// Get available tests
|
// Get available tests
|
||||||
$testsResult = $this->call('get', 'api/test');
|
$testsResult = $this->call('get', 'api/test');
|
||||||
@ -207,4 +132,154 @@ class OrderCreateTest extends CIUnitTestCase
|
|||||||
$this->assertGreaterThanOrEqual(1, count($body['data']['Specimens']), 'Should have at least one specimen');
|
$this->assertGreaterThanOrEqual(1, count($body['data']['Specimens']), 'Should have at least one specimen');
|
||||||
$this->assertGreaterThanOrEqual(2, count($body['data']['Tests']), 'Should have at least two tests');
|
$this->assertGreaterThanOrEqual(2, count($body['data']['Tests']), 'Should have at least two tests');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testOrderShowIncludesDisciplineAndSequenceOrdering()
|
||||||
|
{
|
||||||
|
$internalPID = $this->createOrderTestPatient();
|
||||||
|
$testSiteIDs = $this->collectTestSiteIDs(2);
|
||||||
|
|
||||||
|
$orderID = $this->createOrderWithTests($internalPID, $testSiteIDs);
|
||||||
|
|
||||||
|
$response = $this->call('get', $this->endpoint . '/' . $orderID);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$body = json_decode($response->getBody(), true);
|
||||||
|
$this->assertEquals('success', $body['status']);
|
||||||
|
|
||||||
|
$tests = $body['data']['Tests'] ?? [];
|
||||||
|
$this->assertNotEmpty($tests, 'Tests payload should not be empty');
|
||||||
|
|
||||||
|
$lastKey = null;
|
||||||
|
foreach ($tests as $test) {
|
||||||
|
$this->assertArrayHasKey('Discipline', $test);
|
||||||
|
$this->assertArrayHasKey('TestType', $test);
|
||||||
|
$this->assertNotEmpty($test['TestType'], 'Each test should report a test type');
|
||||||
|
$this->assertArrayHasKey('SeqScr', $test);
|
||||||
|
$this->assertArrayHasKey('SeqRpt', $test);
|
||||||
|
|
||||||
|
$discipline = $test['Discipline'];
|
||||||
|
$this->assertArrayHasKey('DisciplineID', $discipline);
|
||||||
|
$this->assertArrayHasKey('DisciplineName', $discipline);
|
||||||
|
$this->assertArrayHasKey('SeqScr', $discipline);
|
||||||
|
$this->assertArrayHasKey('SeqRpt', $discipline);
|
||||||
|
|
||||||
|
$currentKey = $this->buildTestSortKey($test);
|
||||||
|
if ($lastKey !== null) {
|
||||||
|
$this->assertGreaterThanOrEqual($lastKey, $currentKey, 'Tests are not ordered by discipline/test sequence');
|
||||||
|
}
|
||||||
|
$lastKey = $currentKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOrderTestPatient(): int
|
||||||
|
{
|
||||||
|
$faker = Factory::create('id_ID');
|
||||||
|
$patientPayload = [
|
||||||
|
"PatientID" => "ORD" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
|
||||||
|
"AlternatePID" => "DMY" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000),
|
||||||
|
"Prefix" => $faker->title,
|
||||||
|
"NameFirst" => "Order",
|
||||||
|
"NameMiddle" => $faker->firstName,
|
||||||
|
"NameMaiden" => $faker->firstName,
|
||||||
|
"NameLast" => "Test",
|
||||||
|
"Suffix" => "S.Kom",
|
||||||
|
"NameAlias" => $faker->userName,
|
||||||
|
"Sex" => $faker->numberBetween(5, 6),
|
||||||
|
"PlaceOfBirth" => $faker->city,
|
||||||
|
"Birthdate" => "1990-01-01",
|
||||||
|
"ZIP" => $faker->postcode,
|
||||||
|
"Street_1" => $faker->streetAddress,
|
||||||
|
"Street_2" => "RT " . $faker->numberBetween(1, 10) . " RW " . $faker->numberBetween(1, 10),
|
||||||
|
"Street_3" => "Blok " . $faker->numberBetween(1, 20),
|
||||||
|
"City" => $faker->city,
|
||||||
|
"Province" => $faker->state,
|
||||||
|
"EmailAddress1" => "A" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
|
||||||
|
"EmailAddress2" => "B" . $faker->numberBetween(1, 1000). $faker->numberBetween(1, 1000).$faker->numberBetween(1, 1000).'@gmail.com',
|
||||||
|
"Phone" => $faker->numerify('08##########'),
|
||||||
|
"MobilePhone" => $faker->numerify('08##########'),
|
||||||
|
"Race" => (string) $faker->numberBetween(175, 205),
|
||||||
|
"Country" => (string) $faker->numberBetween(221, 469),
|
||||||
|
"MaritalStatus" => (string) $faker->numberBetween(8, 15),
|
||||||
|
"Religion" => (string) $faker->numberBetween(206, 212),
|
||||||
|
"Ethnic" => (string) $faker->numberBetween(213, 220),
|
||||||
|
"Citizenship" => "WNI",
|
||||||
|
"DeathIndicator" => (string) $faker->numberBetween(16, 17),
|
||||||
|
"LinkTo" => (string) $faker->numberBetween(2, 3),
|
||||||
|
"Custodian" => 1,
|
||||||
|
"PatIdt" => [
|
||||||
|
"IdentifierType" => "KTP",
|
||||||
|
"Identifier" => $faker->nik() ?? $faker->numerify('################')
|
||||||
|
],
|
||||||
|
"PatAtt" => [
|
||||||
|
[ "Address" => "/api/upload/" . $faker->uuid . ".jpg" ]
|
||||||
|
],
|
||||||
|
"PatCom" => $faker->sentence,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($patientPayload['DeathIndicator'] == '16') {
|
||||||
|
$patientPayload['DeathDateTime'] = $faker->date('Y-m-d H:i:s');
|
||||||
|
} else {
|
||||||
|
$patientPayload['DeathDateTime'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientModel = new \App\Models\Patient\PatientModel();
|
||||||
|
$internalPID = $patientModel->createPatient($patientPayload);
|
||||||
|
$this->assertNotNull($internalPID, 'Failed to create test patient. Response: ' . print_r($patientPayload, true));
|
||||||
|
|
||||||
|
return $internalPID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOrderWithTests(int $internalPID, array $testSiteIDs, string $priority = 'R'): string
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'InternalPID' => $internalPID,
|
||||||
|
'Priority' => $priority,
|
||||||
|
'Tests' => array_map(fn ($testSiteID) => ['TestSiteID' => $testSiteID], $testSiteIDs),
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload);
|
||||||
|
$result->assertStatus(201);
|
||||||
|
|
||||||
|
$body = json_decode($result->getBody(), true);
|
||||||
|
$this->assertEquals('success', $body['status']);
|
||||||
|
$orderID = $body['data']['OrderID'] ?? null;
|
||||||
|
$this->assertNotNull($orderID, 'Order creation response is missing OrderID');
|
||||||
|
|
||||||
|
return $orderID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectTestSiteIDs(int $count = 2): array
|
||||||
|
{
|
||||||
|
$response = $this->call('get', 'api/test');
|
||||||
|
$body = json_decode($response->getBody(), true);
|
||||||
|
$availableTests = $body['data'] ?? [];
|
||||||
|
|
||||||
|
if (count($availableTests) < $count) {
|
||||||
|
$this->markTestSkipped('Need at least ' . $count . ' tests to validate ordering.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_filter(array_column($availableTests, 'TestSiteID')));
|
||||||
|
return array_slice($ids, 0, $count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTestSortKey(array $test): string
|
||||||
|
{
|
||||||
|
$discipline = $test['Discipline'] ?? [];
|
||||||
|
$discSeqScr = $this->normalizeSequenceValue($discipline['SeqScr'] ?? null);
|
||||||
|
$discSeqRpt = $this->normalizeSequenceValue($discipline['SeqRpt'] ?? null);
|
||||||
|
$testSeqScr = $this->normalizeSequenceValue($test['SeqScr'] ?? null);
|
||||||
|
$testSeqRpt = $this->normalizeSequenceValue($test['SeqRpt'] ?? null);
|
||||||
|
$resultID = isset($test['ResultID']) ? (int)$test['ResultID'] : 0;
|
||||||
|
|
||||||
|
return sprintf('%06d-%06d-%06d-%06d-%010d', $discSeqScr, $discSeqRpt, $testSeqScr, $testSeqRpt, $resultID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSequenceValue($value): int
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 999999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
271
tests/unit/Rule/RuleDefModelTest.php
Normal file
271
tests/unit/Rule/RuleDefModelTest.php
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Rule;
|
||||||
|
|
||||||
|
use App\Models\Rule\RuleDefModel;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use CodeIgniter\Test\DatabaseTestTrait;
|
||||||
|
|
||||||
|
class RuleDefModelTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
use DatabaseTestTrait;
|
||||||
|
|
||||||
|
protected $model;
|
||||||
|
protected $seed = \App\Database\Seeds\TestSeeder::class;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->model = new RuleDefModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that getActiveByEvent returns empty array when TestSiteID is null
|
||||||
|
* This ensures rules are standalone and must be explicitly included by test
|
||||||
|
*/
|
||||||
|
public function testGetActiveByEventReturnsEmptyWithoutTestSiteID(): void
|
||||||
|
{
|
||||||
|
$rules = $this->model->getActiveByEvent('ORDER_CREATED', null);
|
||||||
|
|
||||||
|
$this->assertIsArray($rules);
|
||||||
|
$this->assertEmpty($rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that a rule can be linked to multiple tests
|
||||||
|
*/
|
||||||
|
public function testRuleCanBeLinkedToMultipleTests(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get two existing tests
|
||||||
|
$tests = $db->table('testdefsite')
|
||||||
|
->where('EndDate', null)
|
||||||
|
->limit(2)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if (count($tests) < 2) {
|
||||||
|
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
|
||||||
|
}
|
||||||
|
|
||||||
|
$testSiteID1 = (int) $tests[0]['TestSiteID'];
|
||||||
|
$testSiteID2 = (int) $tests[1]['TestSiteID'];
|
||||||
|
|
||||||
|
// Create a rule
|
||||||
|
$ruleData = [
|
||||||
|
'RuleCode' => 'MULTI_TEST_RULE',
|
||||||
|
'RuleName' => 'Multi Test Rule',
|
||||||
|
'EventCode' => 'ORDER_CREATED',
|
||||||
|
'ConditionExpr' => 'order["InternalOID"] > 0',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ruleID = $this->model->insert($ruleData, true);
|
||||||
|
$this->assertNotFalse($ruleID);
|
||||||
|
|
||||||
|
// Link rule to both tests
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID1);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID2);
|
||||||
|
|
||||||
|
// Verify rule is returned for both test sites
|
||||||
|
$rules1 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1);
|
||||||
|
$this->assertNotEmpty($rules1);
|
||||||
|
$this->assertCount(1, $rules1);
|
||||||
|
$this->assertEquals($ruleID, $rules1[0]['RuleID']);
|
||||||
|
|
||||||
|
$rules2 = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2);
|
||||||
|
$this->assertNotEmpty($rules2);
|
||||||
|
$this->assertCount(1, $rules2);
|
||||||
|
$this->assertEquals($ruleID, $rules2[0]['RuleID']);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
$this->model->delete($ruleID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that rules only work when explicitly linked to a test
|
||||||
|
*/
|
||||||
|
public function testRulesOnlyWorkWhenExplicitlyLinked(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get an existing test
|
||||||
|
$test = $db->table('testdefsite')
|
||||||
|
->where('EndDate', null)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!$test) {
|
||||||
|
$this->markTestSkipped('No tests available in testdefsite table');
|
||||||
|
}
|
||||||
|
|
||||||
|
$testSiteID = (int) $test['TestSiteID'];
|
||||||
|
|
||||||
|
// Create a rule (not linked to any test yet)
|
||||||
|
$ruleData = [
|
||||||
|
'RuleCode' => 'UNLINKED_RULE',
|
||||||
|
'RuleName' => 'Unlinked Test Rule',
|
||||||
|
'EventCode' => 'ORDER_CREATED',
|
||||||
|
'ConditionExpr' => 'true',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ruleID = $this->model->insert($ruleData, true);
|
||||||
|
$this->assertNotFalse($ruleID);
|
||||||
|
|
||||||
|
// Verify rule is NOT returned when not linked
|
||||||
|
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
||||||
|
$this->assertEmpty($rules);
|
||||||
|
|
||||||
|
// Now link the rule
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID);
|
||||||
|
|
||||||
|
// Verify rule is now returned
|
||||||
|
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
||||||
|
$this->assertNotEmpty($rules);
|
||||||
|
$this->assertCount(1, $rules);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
$this->model->delete($ruleID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that unlinking a test removes the rule for that test
|
||||||
|
*/
|
||||||
|
public function testUnlinkingTestRemovesRule(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get two existing tests
|
||||||
|
$tests = $db->table('testdefsite')
|
||||||
|
->where('EndDate', null)
|
||||||
|
->limit(2)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if (count($tests) < 2) {
|
||||||
|
$this->markTestSkipped('Need at least 2 tests available in testdefsite table');
|
||||||
|
}
|
||||||
|
|
||||||
|
$testSiteID1 = (int) $tests[0]['TestSiteID'];
|
||||||
|
$testSiteID2 = (int) $tests[1]['TestSiteID'];
|
||||||
|
|
||||||
|
// Create a rule and link to both tests
|
||||||
|
$ruleData = [
|
||||||
|
'RuleCode' => 'UNLINK_TEST',
|
||||||
|
'RuleName' => 'Unlink Test Rule',
|
||||||
|
'EventCode' => 'ORDER_CREATED',
|
||||||
|
'ConditionExpr' => 'true',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ruleID = $this->model->insert($ruleData, true);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID1);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID2);
|
||||||
|
|
||||||
|
// Verify rule is returned for both
|
||||||
|
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
|
||||||
|
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
|
||||||
|
|
||||||
|
// Unlink from first test
|
||||||
|
$this->model->unlinkTest($ruleID, $testSiteID1);
|
||||||
|
|
||||||
|
// Verify rule is NOT returned for first test but still for second
|
||||||
|
$this->assertEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID1));
|
||||||
|
$this->assertNotEmpty($this->model->getActiveByEvent('ORDER_CREATED', $testSiteID2));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
$this->model->delete($ruleID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that deleted (soft deleted) rules are not returned
|
||||||
|
*/
|
||||||
|
public function testDeletedRulesAreNotReturned(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
$test = $db->table('testdefsite')
|
||||||
|
->where('EndDate', null)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (!$test) {
|
||||||
|
$this->markTestSkipped('No tests available in testdefsite table');
|
||||||
|
}
|
||||||
|
|
||||||
|
$testSiteID = (int) $test['TestSiteID'];
|
||||||
|
|
||||||
|
// Create a rule and link it
|
||||||
|
$ruleData = [
|
||||||
|
'RuleCode' => 'DELETED_RULE',
|
||||||
|
'RuleName' => 'Deleted Test Rule',
|
||||||
|
'EventCode' => 'ORDER_CREATED',
|
||||||
|
'ConditionExpr' => 'true',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ruleID = $this->model->insert($ruleData, true);
|
||||||
|
$this->assertNotFalse($ruleID);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID);
|
||||||
|
|
||||||
|
// Verify rule is returned
|
||||||
|
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
||||||
|
$this->assertNotEmpty($rules);
|
||||||
|
|
||||||
|
// Soft delete the rule
|
||||||
|
$this->model->delete($ruleID);
|
||||||
|
|
||||||
|
// Verify deleted rule is NOT returned
|
||||||
|
$rules = $this->model->getActiveByEvent('ORDER_CREATED', $testSiteID);
|
||||||
|
$this->assertEmpty($rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test getting linked tests for a rule
|
||||||
|
*/
|
||||||
|
public function testGetLinkedTests(): void
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// Get two existing tests
|
||||||
|
$tests = $db->table('testdefsite')
|
||||||
|
->where('EndDate', null)
|
||||||
|
->limit(2)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if (count($tests) < 2) {
|
||||||
|
$this->markTestSkipped('Need at least 2 tests available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$testSiteID1 = (int) $tests[0]['TestSiteID'];
|
||||||
|
$testSiteID2 = (int) $tests[1]['TestSiteID'];
|
||||||
|
|
||||||
|
// Create a rule
|
||||||
|
$ruleData = [
|
||||||
|
'RuleCode' => 'LINKED_TESTS',
|
||||||
|
'RuleName' => 'Linked Tests Rule',
|
||||||
|
'EventCode' => 'ORDER_CREATED',
|
||||||
|
'ConditionExpr' => 'true',
|
||||||
|
'CreateDate' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$ruleID = $this->model->insert($ruleData, true);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID1);
|
||||||
|
$this->model->linkTest($ruleID, $testSiteID2);
|
||||||
|
|
||||||
|
// Get linked tests
|
||||||
|
$linkedTests = $this->model->getLinkedTests($ruleID);
|
||||||
|
|
||||||
|
$this->assertCount(2, $linkedTests);
|
||||||
|
$this->assertContains($testSiteID1, $linkedTests);
|
||||||
|
$this->assertContains($testSiteID2, $linkedTests);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
$this->model->delete($ruleID);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/unit/Rules/RuleExpressionCompileTest.php
Normal file
60
tests/unit/Rules/RuleExpressionCompileTest.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Rules;
|
||||||
|
|
||||||
|
use App\Services\RuleExpressionService;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
|
||||||
|
class RuleExpressionCompileTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) {
|
||||||
|
$this->markTestSkipped('Symfony ExpressionLanguage not installed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompileSexCondition(): void
|
||||||
|
{
|
||||||
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
|
$compiled = $svc->compile("if(sex('F') ? set_result(0.7) : set_result(1))");
|
||||||
|
|
||||||
|
$this->assertIsArray($compiled);
|
||||||
|
$this->assertEquals('order["Sex"] == "F"', $compiled['conditionExpr']);
|
||||||
|
$this->assertEquals(0.7, $compiled['then']['value']);
|
||||||
|
$this->assertEquals(1, $compiled['else']['value']);
|
||||||
|
$this->assertStringContainsString('order["Sex"] == "F"', $compiled['valueExpr']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompilePriorityCondition(): void
|
||||||
|
{
|
||||||
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
|
$compiled = $svc->compile("if(priority('S') ? set_result('urgent') : set_result('normal'))");
|
||||||
|
|
||||||
|
$this->assertIsArray($compiled);
|
||||||
|
$this->assertEquals('order["Priority"] == "S"', $compiled['conditionExpr']);
|
||||||
|
$this->assertEquals('urgent', $compiled['then']['value']);
|
||||||
|
$this->assertEquals('normal', $compiled['else']['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompileInvalidSyntax(): void
|
||||||
|
{
|
||||||
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$svc->compile("invalid syntax here");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompileEmptyReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$svc = new RuleExpressionService();
|
||||||
|
|
||||||
|
$compiled = $svc->compile("");
|
||||||
|
|
||||||
|
$this->assertIsArray($compiled);
|
||||||
|
$this->assertEmpty($compiled);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user