diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 6de44a0..72ca74a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -346,6 +346,21 @@ $routes->group('api', function ($routes) { $routes->post('status', 'OrderTestController::updateStatus'); }); + // Rules + $routes->group('rules', function ($routes) { + $routes->get('/', 'Rule\RuleController::index'); + $routes->get('(:num)', 'Rule\RuleController::show/$1'); + $routes->post('/', 'Rule\RuleController::create'); + $routes->patch('(:num)', 'Rule\RuleController::update/$1'); + $routes->delete('(:num)', 'Rule\RuleController::delete/$1'); + $routes->post('validate', 'Rule\RuleController::validateExpr'); + + $routes->get('(:num)/actions', 'Rule\RuleActionController::index/$1'); + $routes->post('(:num)/actions', 'Rule\RuleActionController::create/$1'); + $routes->patch('(:num)/actions/(:num)', 'Rule\RuleActionController::update/$1/$2'); + $routes->delete('(:num)/actions/(:num)', 'Rule\RuleActionController::delete/$1/$2'); + }); + // Demo/Test Routes (No Auth) $routes->group('api/demo', function ($routes) { $routes->post('order', 'Test\DemoOrderController::createDemoOrder'); diff --git a/app/Controllers/OrderTestController.php b/app/Controllers/OrderTestController.php index f64d511..f82f770 100644 --- a/app/Controllers/OrderTestController.php +++ b/app/Controllers/OrderTestController.php @@ -7,6 +7,7 @@ use App\Libraries\ValueSet; use App\Models\OrderTest\OrderTestModel; use App\Models\Patient\PatientModel; use App\Models\PatVisit\PatVisitModel; +use App\Services\RuleEngineService; class OrderTestController extends Controller { use ResponseTrait; @@ -147,12 +148,27 @@ class OrderTestController extends Controller { } $orderID = $this->model->createOrder($input); - + // Fetch complete order details $order = $this->model->getOrder($orderID); $order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']); $order['Tests'] = $this->getOrderTests($order['InternalOID']); + // Run common rules for ORDER_CREATED (non-blocking) + try { + $ruleEngine = new RuleEngineService(); + $ruleEngine->run('ORDER_CREATED', [ + 'order' => $order, + 'tests' => $order['Tests'], + 'input' => $input, + ]); + + // Refresh tests in case rules updated results + $order['Tests'] = $this->getOrderTests($order['InternalOID']); + } catch (\Throwable $e) { + log_message('error', 'OrderTestController::create rule engine error: ' . $e->getMessage()); + } + return $this->respondCreated([ 'status' => 'success', 'message' => 'Order created successfully', diff --git a/app/Controllers/Rule/RuleActionController.php b/app/Controllers/Rule/RuleActionController.php new file mode 100644 index 0000000..2e2c2ae --- /dev/null +++ b/app/Controllers/Rule/RuleActionController.php @@ -0,0 +1,231 @@ +ruleDefModel = new RuleDefModel(); + $this->ruleActionModel = new RuleActionModel(); + } + + public function index($ruleID = null) + { + try { + if (!$ruleID || !is_numeric($ruleID)) { + return $this->failValidationErrors('RuleID is required'); + } + + $rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID); + if (!$rule) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $rows = $this->ruleActionModel + ->where('RuleID', (int) $ruleID) + ->where('EndDate', null) + ->orderBy('Seq', 'ASC') + ->orderBy('RuleActionID', 'ASC') + ->findAll(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'fetch success', + 'data' => $rows, + ], 200); + } catch (\Throwable $e) { + log_message('error', 'RuleActionController::index error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function create($ruleID = null) + { + $input = $this->request->getJSON(true) ?? []; + + if (!$ruleID || !is_numeric($ruleID)) { + $ruleID = $input['RuleID'] ?? null; + } + if (!$ruleID || !is_numeric($ruleID)) { + return $this->failValidationErrors('RuleID is required'); + } + + $rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID); + if (!$rule) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $validation = service('validation'); + $validation->setRules([ + 'Seq' => 'permit_empty|integer', + 'ActionType' => 'required|max_length[50]', + ]); + + if (!$validation->run($input)) { + return $this->failValidationErrors($validation->getErrors()); + } + + // Light validation for SET_RESULT params + $actionType = strtoupper((string) $input['ActionType']); + $params = $input['ActionParams'] ?? null; + if ($actionType === 'SET_RESULT') { + $decoded = is_array($params) ? $params : (is_string($params) ? json_decode($params, true) : null); + if (!is_array($decoded)) { + return $this->failValidationErrors(['ActionParams' => 'ActionParams must be JSON object for SET_RESULT']); + } + + if (empty($decoded['testSiteID']) && empty($decoded['testSiteCode'])) { + return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires testSiteID or testSiteCode']); + } + if (!array_key_exists('value', $decoded) && !array_key_exists('valueExpr', $decoded)) { + return $this->failValidationErrors(['ActionParams' => 'SET_RESULT requires value or valueExpr']); + } + + if (!empty($decoded['testSiteID']) && is_numeric($decoded['testSiteID'])) { + $testDef = new TestDefSiteModel(); + $exists = $testDef->where('EndDate', null)->find((int) $decoded['testSiteID']); + if (!$exists) { + return $this->failValidationErrors(['ActionParams' => 'testSiteID not found']); + } + } + } + + try { + if (is_array($params)) { + $params = json_encode($params); + } + + $id = $this->ruleActionModel->insert([ + 'RuleID' => (int) $ruleID, + 'Seq' => $input['Seq'] ?? 1, + 'ActionType' => $input['ActionType'], + 'ActionParams' => is_string($params) ? $params : null, + ], true); + + return $this->respondCreated([ + 'status' => 'success', + 'message' => 'Action created successfully', + 'data' => ['RuleActionID' => $id], + ], 201); + } catch (\Throwable $e) { + log_message('error', 'RuleActionController::create error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function update($ruleID = null, $actionID = null) + { + $input = $this->request->getJSON(true) ?? []; + + if (!$ruleID || !is_numeric($ruleID)) { + $ruleID = $input['RuleID'] ?? null; + } + if (!$actionID || !is_numeric($actionID)) { + $actionID = $input['RuleActionID'] ?? null; + } + if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) { + return $this->failValidationErrors('RuleID and RuleActionID are required'); + } + + $rule = $this->ruleDefModel->where('EndDate', null)->find((int) $ruleID); + if (!$rule) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID); + if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Action not found', + 'data' => [], + ], 404); + } + + $validation = service('validation'); + $validation->setRules([ + 'Seq' => 'permit_empty|integer', + 'ActionType' => 'permit_empty|max_length[50]', + ]); + if (!$validation->run($input)) { + return $this->failValidationErrors($validation->getErrors()); + } + + try { + $updateData = []; + foreach (['Seq', 'ActionType', 'ActionParams'] as $field) { + if (array_key_exists($field, $input)) { + $updateData[$field] = $input[$field]; + } + } + if (isset($updateData['ActionParams']) && is_array($updateData['ActionParams'])) { + $updateData['ActionParams'] = json_encode($updateData['ActionParams']); + } + + if (!empty($updateData)) { + $this->ruleActionModel->update((int) $actionID, $updateData); + } + + return $this->respond([ + 'status' => 'success', + 'message' => 'Action updated successfully', + 'data' => ['RuleActionID' => (int) $actionID], + ], 200); + } catch (\Throwable $e) { + log_message('error', 'RuleActionController::update error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function delete($ruleID = null, $actionID = null) + { + try { + if (!$ruleID || !is_numeric($ruleID) || !$actionID || !is_numeric($actionID)) { + return $this->failValidationErrors('RuleID and RuleActionID are required'); + } + + $existing = $this->ruleActionModel->where('EndDate', null)->find((int) $actionID); + if (!$existing || (int) ($existing['RuleID'] ?? 0) !== (int) $ruleID) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Action not found', + 'data' => [], + ], 404); + } + + $this->ruleActionModel->delete((int) $actionID); + + return $this->respondDeleted([ + 'status' => 'success', + 'message' => 'Action deleted successfully', + 'data' => ['RuleActionID' => (int) $actionID], + ]); + } catch (\Throwable $e) { + log_message('error', 'RuleActionController::delete error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } +} diff --git a/app/Controllers/Rule/RuleController.php b/app/Controllers/Rule/RuleController.php new file mode 100644 index 0000000..0ee3abb --- /dev/null +++ b/app/Controllers/Rule/RuleController.php @@ -0,0 +1,344 @@ +ruleDefModel = new RuleDefModel(); + $this->ruleActionModel = new RuleActionModel(); + } + + public function index() + { + try { + $eventCode = $this->request->getGet('EventCode'); + $active = $this->request->getGet('Active'); + $scopeType = $this->request->getGet('ScopeType'); + $testSiteID = $this->request->getGet('TestSiteID'); + $search = $this->request->getGet('search'); + + $builder = $this->ruleDefModel->where('EndDate', null); + + if ($eventCode !== null && $eventCode !== '') { + $builder->where('EventCode', $eventCode); + } + if ($active !== null && $active !== '') { + $builder->where('Active', (int) $active); + } + if ($scopeType !== null && $scopeType !== '') { + $builder->where('ScopeType', $scopeType); + } + if ($testSiteID !== null && $testSiteID !== '' && is_numeric($testSiteID)) { + $builder->where('TestSiteID', (int) $testSiteID); + } + if ($search !== null && $search !== '') { + $builder->like('Name', $search); + } + + $rows = $builder + ->orderBy('Priority', 'ASC') + ->orderBy('RuleID', 'ASC') + ->findAll(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'fetch success', + 'data' => $rows, + ], 200); + } catch (\Throwable $e) { + log_message('error', 'RuleController::index error: ' . $e->getMessage()); + return $this->respond([ + 'status' => 'failed', + 'message' => 'Failed to fetch rules', + 'data' => [], + ], 500); + } + } + + public function show($id = null) + { + try { + if (!$id || !is_numeric($id)) { + return $this->failValidationErrors('RuleID is required'); + } + + $rule = $this->ruleDefModel->where('EndDate', null)->find((int) $id); + if (!$rule) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $actions = $this->ruleActionModel + ->where('RuleID', (int) $id) + ->where('EndDate', null) + ->orderBy('Seq', 'ASC') + ->orderBy('RuleActionID', 'ASC') + ->findAll(); + + $rule['actions'] = $actions; + + return $this->respond([ + 'status' => 'success', + 'message' => 'fetch success', + 'data' => $rule, + ], 200); + } catch (\Throwable $e) { + log_message('error', 'RuleController::show error: ' . $e->getMessage()); + return $this->respond([ + 'status' => 'failed', + 'message' => 'Failed to fetch rule', + 'data' => [], + ], 500); + } + } + + public function create() + { + $input = $this->request->getJSON(true) ?? []; + + $validation = service('validation'); + $validation->setRules([ + 'Name' => 'required|max_length[100]', + 'EventCode' => 'required|max_length[50]', + 'ScopeType' => 'required|in_list[GLOBAL,TESTSITE]', + 'TestSiteID' => 'permit_empty|is_natural_no_zero', + 'ConditionExpr' => 'permit_empty|max_length[1000]', + 'Priority' => 'permit_empty|integer', + 'Active' => 'permit_empty|in_list[0,1]', + ]); + + if (!$validation->run($input)) { + return $this->failValidationErrors($validation->getErrors()); + } + + if (($input['ScopeType'] ?? 'GLOBAL') === 'TESTSITE') { + if (empty($input['TestSiteID'])) { + return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']); + } + $testDef = new TestDefSiteModel(); + $exists = $testDef->where('EndDate', null)->find((int) $input['TestSiteID']); + if (!$exists) { + return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']); + } + } else { + $input['TestSiteID'] = null; + } + + $db = \Config\Database::connect(); + $db->transStart(); + + try { + $ruleData = [ + 'Name' => $input['Name'], + 'Description' => $input['Description'] ?? null, + 'EventCode' => $input['EventCode'], + 'ScopeType' => $input['ScopeType'], + 'TestSiteID' => $input['TestSiteID'] ?? null, + 'ConditionExpr' => $input['ConditionExpr'] ?? null, + 'Priority' => $input['Priority'] ?? 100, + 'Active' => isset($input['Active']) ? (int) $input['Active'] : 1, + ]; + + $ruleID = $this->ruleDefModel->insert($ruleData, true); + if (!$ruleID) { + throw new \Exception('Failed to create rule'); + } + + if (isset($input['actions']) && is_array($input['actions'])) { + foreach ($input['actions'] as $action) { + if (!is_array($action)) { + continue; + } + + $actionType = $action['ActionType'] ?? null; + if (!$actionType) { + continue; + } + + $params = $action['ActionParams'] ?? null; + if (is_array($params)) { + $params = json_encode($params); + } + + $this->ruleActionModel->insert([ + 'RuleID' => $ruleID, + 'Seq' => $action['Seq'] ?? 1, + 'ActionType' => $actionType, + 'ActionParams' => is_string($params) ? $params : null, + ]); + } + } + + $db->transComplete(); + if ($db->transStatus() === false) { + throw new \Exception('Transaction failed'); + } + + return $this->respondCreated([ + 'status' => 'success', + 'message' => 'Rule created successfully', + 'data' => ['RuleID' => $ruleID], + ], 201); + } catch (\Throwable $e) { + $db->transRollback(); + log_message('error', 'RuleController::create error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function update($id = null) + { + $input = $this->request->getJSON(true) ?? []; + + if (!$id || !is_numeric($id)) { + $id = $input['RuleID'] ?? null; + } + if (!$id || !is_numeric($id)) { + return $this->failValidationErrors('RuleID is required'); + } + + $existing = $this->ruleDefModel->where('EndDate', null)->find((int) $id); + if (!$existing) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $validation = service('validation'); + $validation->setRules([ + 'Name' => 'permit_empty|max_length[100]', + 'EventCode' => 'permit_empty|max_length[50]', + 'ScopeType' => 'permit_empty|in_list[GLOBAL,TESTSITE]', + 'TestSiteID' => 'permit_empty|is_natural_no_zero', + 'ConditionExpr' => 'permit_empty|max_length[1000]', + 'Priority' => 'permit_empty|integer', + 'Active' => 'permit_empty|in_list[0,1]', + ]); + + if (!$validation->run($input)) { + return $this->failValidationErrors($validation->getErrors()); + } + + $scopeType = $input['ScopeType'] ?? $existing['ScopeType'] ?? 'GLOBAL'; + $testSiteID = array_key_exists('TestSiteID', $input) ? $input['TestSiteID'] : ($existing['TestSiteID'] ?? null); + + if ($scopeType === 'TESTSITE') { + if (empty($testSiteID)) { + return $this->failValidationErrors(['TestSiteID' => 'TestSiteID is required for TESTSITE scope']); + } + $testDef = new TestDefSiteModel(); + $exists = $testDef->where('EndDate', null)->find((int) $testSiteID); + if (!$exists) { + return $this->failValidationErrors(['TestSiteID' => 'TestSiteID not found']); + } + } else { + $testSiteID = null; + } + + try { + $updateData = []; + foreach (['Name', 'Description', 'EventCode', 'ScopeType', 'ConditionExpr', 'Priority', 'Active'] as $field) { + if (array_key_exists($field, $input)) { + $updateData[$field] = $input[$field]; + } + } + $updateData['TestSiteID'] = $testSiteID; + + if (!empty($updateData)) { + $this->ruleDefModel->update((int) $id, $updateData); + } + + return $this->respond([ + 'status' => 'success', + 'message' => 'Rule updated successfully', + 'data' => ['RuleID' => (int) $id], + ], 200); + } catch (\Throwable $e) { + log_message('error', 'RuleController::update error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function delete($id = null) + { + try { + if (!$id || !is_numeric($id)) { + return $this->failValidationErrors('RuleID is required'); + } + + $existing = $this->ruleDefModel->where('EndDate', null)->find((int) $id); + if (!$existing) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Rule not found', + 'data' => [], + ], 404); + } + + $this->ruleDefModel->delete((int) $id); + + return $this->respondDeleted([ + 'status' => 'success', + 'message' => 'Rule deleted successfully', + 'data' => ['RuleID' => (int) $id], + ]); + } catch (\Throwable $e) { + log_message('error', 'RuleController::delete error: ' . $e->getMessage()); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + public function validateExpr() + { + $input = $this->request->getJSON(true) ?? []; + $expr = $input['expr'] ?? ''; + $context = $input['context'] ?? []; + + if (!is_string($expr) || trim($expr) === '') { + return $this->failValidationErrors(['expr' => 'expr is required']); + } + if (!is_array($context)) { + return $this->failValidationErrors(['context' => 'context must be an object']); + } + + try { + $svc = new RuleExpressionService(); + $result = $svc->evaluate($expr, $context); + + return $this->respond([ + 'status' => 'success', + 'data' => [ + 'valid' => true, + 'result' => $result, + ], + ], 200); + } catch (\Throwable $e) { + return $this->respond([ + 'status' => 'failed', + 'data' => [ + 'valid' => false, + 'error' => $e->getMessage(), + ], + ], 200); + } + } +} diff --git a/app/Database/Migrations/2026-03-11-000030_CreateRules.php b/app/Database/Migrations/2026-03-11-000030_CreateRules.php new file mode 100644 index 0000000..455ffa1 --- /dev/null +++ b/app/Database/Migrations/2026-03-11-000030_CreateRules.php @@ -0,0 +1,57 @@ +forge->addField([ + 'RuleID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'Name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false], + 'Description' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'EventCode' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'ScopeType' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'GLOBAL'], + 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'ConditionExpr' => ['type' => 'VARCHAR', 'constraint' => 1000, 'null' => true], + 'Priority' => ['type' => 'INT', 'null' => true, 'default' => 100], + 'Active' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true, 'default' => 1], + 'CreateDate' => ['type' => 'DATETIME', 'null' => true], + 'StartDate' => ['type' => 'DATETIME', 'null' => true], + 'EndDate' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('RuleID', true); + $this->forge->addKey('EventCode'); + $this->forge->addKey('ScopeType'); + $this->forge->addKey('TestSiteID'); + $this->forge->createTable('ruledef'); + + // Optional scope FK (only when ScopeType=TESTSITE) + $this->db->query('ALTER TABLE `ruledef` ADD CONSTRAINT `fk_ruledef_testsite` FOREIGN KEY (`TestSiteID`) REFERENCES `testdefsite`(`TestSiteID`) ON DELETE CASCADE ON UPDATE CASCADE'); + + // ruleaction + $this->forge->addField([ + 'RuleActionID' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'RuleID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], + 'Seq' => ['type' => 'INT', 'null' => true, 'default' => 1], + 'ActionType' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'ActionParams' => ['type' => 'TEXT', 'null' => true], + 'CreateDate' => ['type' => 'DATETIME', 'null' => true], + 'EndDate' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('RuleActionID', true); + $this->forge->addKey('RuleID'); + $this->forge->createTable('ruleaction'); + + $this->db->query('ALTER TABLE `ruleaction` ADD CONSTRAINT `fk_ruleaction_ruledef` FOREIGN KEY (`RuleID`) REFERENCES `ruledef`(`RuleID`) ON DELETE CASCADE ON UPDATE CASCADE'); + } + + public function down() + { + $this->forge->dropTable('ruleaction'); + $this->forge->dropTable('ruledef'); + } +} diff --git a/app/Models/Rule/RuleActionModel.php b/app/Models/Rule/RuleActionModel.php new file mode 100644 index 0000000..7e8be09 --- /dev/null +++ b/app/Models/Rule/RuleActionModel.php @@ -0,0 +1,40 @@ +whereIn('RuleID', $ruleIDs) + ->where('EndDate IS NULL') + ->orderBy('RuleID', 'ASC') + ->orderBy('Seq', 'ASC') + ->orderBy('RuleActionID', 'ASC') + ->findAll(); + } +} diff --git a/app/Models/Rule/RuleDefModel.php b/app/Models/Rule/RuleDefModel.php new file mode 100644 index 0000000..3a6e996 --- /dev/null +++ b/app/Models/Rule/RuleDefModel.php @@ -0,0 +1,61 @@ +where('EventCode', $eventCode) + ->where('EndDate IS NULL') + ->where('Active', 1) + ->groupStart() + ->where('ScopeType', 'GLOBAL'); + + if ($testSiteID !== null) { + $builder->orGroupStart() + ->where('ScopeType', 'TESTSITE') + ->where('TestSiteID', $testSiteID) + ->groupEnd(); + } + + $builder->groupEnd(); + + return $builder + ->orderBy('Priority', 'ASC') + ->orderBy('RuleID', 'ASC') + ->findAll(); + } +} diff --git a/app/Services/RuleEngineService.php b/app/Services/RuleEngineService.php new file mode 100644 index 0000000..8b27954 --- /dev/null +++ b/app/Services/RuleEngineService.php @@ -0,0 +1,145 @@ +ruleDefModel = new RuleDefModel(); + $this->ruleActionModel = new RuleActionModel(); + $this->expr = new RuleExpressionService(); + } + + /** + * Run rules for an event. + * + * Expected context keys for ORDER_CREATED: + * - order: array (must include InternalOID) + * - tests: array (patres rows, optional) + */ + public function run(string $eventCode, array $context = []): void + { + $order = $context['order'] ?? null; + $testSiteID = $context['testSiteID'] ?? null; + + if (is_array($order) && isset($order['TestSiteID']) && $testSiteID === null) { + $testSiteID = is_numeric($order['TestSiteID']) ? (int) $order['TestSiteID'] : null; + } + + $rules = $this->ruleDefModel->getActiveByEvent($eventCode, $testSiteID); + if (empty($rules)) { + return; + } + + $ruleIDs = array_values(array_filter(array_map(static fn ($r) => $r['RuleID'] ?? null, $rules))); + $actions = $this->ruleActionModel->getActiveByRuleIDs($ruleIDs); + + $actionsByRule = []; + foreach ($actions as $action) { + $rid = $action['RuleID'] ?? null; + if (!$rid) { + continue; + } + $actionsByRule[$rid][] = $action; + } + + foreach ($rules as $rule) { + $rid = (int) ($rule['RuleID'] ?? 0); + if ($rid === 0) { + continue; + } + + try { + $matches = $this->expr->evaluateBoolean($rule['ConditionExpr'] ?? null, $context); + if (!$matches) { + continue; + } + + foreach ($actionsByRule[$rid] ?? [] as $action) { + $this->executeAction($action, $context); + } + } catch (\Throwable $e) { + log_message('error', 'Rule engine error (RuleID=' . $rid . '): ' . $e->getMessage()); + continue; + } + } + } + + protected function executeAction(array $action, array $context): void + { + $type = strtoupper((string) ($action['ActionType'] ?? '')); + + if ($type === 'SET_RESULT') { + $this->executeSetResult($action, $context); + return; + } + + // Unknown action type: ignore + } + + /** + * SET_RESULT action params (JSON): + * - testSiteID (int) OR testSiteCode (string) + * - value (scalar) OR valueExpr (ExpressionLanguage string) + */ + protected function executeSetResult(array $action, array $context): void + { + $paramsRaw = (string) ($action['ActionParams'] ?? ''); + $params = []; + + if (trim($paramsRaw) !== '') { + $decoded = json_decode($paramsRaw, true); + if (is_array($decoded)) { + $params = $decoded; + } + } + + $order = $context['order'] ?? null; + if (!is_array($order) || empty($order['InternalOID'])) { + throw new \Exception('SET_RESULT requires context.order.InternalOID'); + } + + $internalOID = (int) $order['InternalOID']; + + $testSiteID = isset($params['testSiteID']) && is_numeric($params['testSiteID']) + ? (int) $params['testSiteID'] + : null; + + if ($testSiteID === null && !empty($params['testSiteCode'])) { + $testSiteCode = (string) $params['testSiteCode']; + $testDefSiteModel = new TestDefSiteModel(); + $row = $testDefSiteModel->where('TestSiteCode', $testSiteCode)->where('EndDate', null)->first(); + $testSiteID = isset($row['TestSiteID']) ? (int) $row['TestSiteID'] : null; + } + + if ($testSiteID === null) { + throw new \Exception('SET_RESULT requires testSiteID or testSiteCode'); + } + + if (array_key_exists('valueExpr', $params) && is_string($params['valueExpr'])) { + $value = $this->expr->evaluate($params['valueExpr'], $context); + } else { + $value = $params['value'] ?? null; + } + + $db = \Config\Database::connect(); + $ok = $db->table('patres') + ->where('OrderID', $internalOID) + ->where('TestSiteID', $testSiteID) + ->where('DelDate', null) + ->update(['Result' => $value]); + + if ($ok === false) { + throw new \Exception('SET_RESULT update failed'); + } + } +} diff --git a/app/Services/RuleExpressionService.php b/app/Services/RuleExpressionService.php new file mode 100644 index 0000000..d4c989b --- /dev/null +++ b/app/Services/RuleExpressionService.php @@ -0,0 +1,46 @@ + */ + protected array $parsedCache = []; + + public function __construct() + { + $this->language = new ExpressionLanguage(); + } + + public function evaluate(string $expr, array $context = []) + { + $expr = trim($expr); + if ($expr === '') { + return null; + } + + $names = array_keys($context); + sort($names); + $cacheKey = md5($expr . '|' . implode(',', $names)); + + if (!isset($this->parsedCache[$cacheKey])) { + $this->parsedCache[$cacheKey] = $this->language->parse($expr, $names); + } + + return $this->language->evaluate($this->parsedCache[$cacheKey], $context); + } + + public function evaluateBoolean(?string $expr, array $context = []): bool + { + if ($expr === null || trim($expr) === '') { + return true; + } + + return (bool) $this->evaluate($expr, $context); + } +} diff --git a/composer.json b/composer.json index 733a0da..29cfa51 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "php": "^8.1", "codeigniter4/framework": "^4.0", "firebase/php-jwt": "^6.11", - "mossadal/math-parser": "^1.3" + "mossadal/math-parser": "^1.3", + "symfony/expression-language": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/composer.lock b/composer.lock index 8d0b44d..ffb31d3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8fffd5cbb5e940a076c93e72a52f7734", + "content-hash": "b111968eaeab80698adb5ca0eaeeb8c1", "packages": [ { "name": "codeigniter4/framework", @@ -255,6 +255,108 @@ }, "time": "2018-09-15T22:20:34+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -304,6 +406,489 @@ "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "665522ec357540e66c294c08583b40ee576574f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/665522ec357540e66c294c08583b40ee576574f0", + "reference": "665522ec357540e66c294c08583b40ee576574f0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T08:14:57+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-13T15:25:07+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/expression-language", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", + "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T08:47:25+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T10:15:23+00:00" } ], "packages-dev": [ @@ -1088,59 +1673,6 @@ ], "time": "2025-06-20T11:29:11+00:00" }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, { "name": "sebastian/cli-parser", "version": "2.0.1", @@ -2057,73 +2589,6 @@ ], "time": "2023-02-07T11:34:05+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.6.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:21:43+00:00" - }, { "name": "theseer/tokenizer", "version": "1.2.3", @@ -2184,5 +2649,5 @@ "php": "^8.1" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 314c9c2..8026e05 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -53,6 +53,8 @@ tags: description: Laboratory equipment and instrument management - name: Users description: User management and administration + - name: Rules + description: Common rule engine (events, conditions, actions) paths: /api/auth/login: post: @@ -3056,6 +3058,354 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /api/rules: + get: + tags: + - Rules + summary: List rules + security: + - bearerAuth: [] + parameters: + - name: EventCode + in: query + schema: + type: string + description: Filter by event code + - name: Active + in: query + schema: + type: integer + enum: + - 0 + - 1 + description: Filter by active flag + - name: ScopeType + in: query + schema: + type: string + enum: + - GLOBAL + - TESTSITE + description: Filter by scope type + - name: TestSiteID + in: query + schema: + type: integer + description: Filter by TestSiteID (for TESTSITE scope) + - name: search + in: query + schema: + type: string + description: Search by rule name + responses: + '200': + description: List of rules + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/RuleDef' + post: + tags: + - Rules + summary: Create rule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: + type: string + Description: + type: string + EventCode: + type: string + example: ORDER_CREATED + ScopeType: + type: string + enum: + - GLOBAL + - TESTSITE + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: + - 0 + - 1 + actions: + type: array + items: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: + - Name + - EventCode + - ScopeType + responses: + '201': + description: Rule created + /api/rules/{id}: + get: + tags: + - Rules + summary: Get rule (with actions) + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule details + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + $ref: '#/components/schemas/RuleWithActions' + '404': + description: Rule not found + patch: + tags: + - Rules + summary: Update rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: + type: string + Description: + type: string + EventCode: + type: string + ScopeType: + type: string + enum: + - GLOBAL + - TESTSITE + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: + - 0 + - 1 + responses: + '200': + description: Rule updated + '404': + description: Rule not found + delete: + tags: + - Rules + summary: Soft delete rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule deleted + '404': + description: Rule not found + /api/rules/validate: + post: + tags: + - Rules + summary: Validate/evaluate an expression + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expr: + type: string + context: + type: object + additionalProperties: true + required: + - expr + responses: + '200': + description: Validation result + /api/rules/{id}/actions: + get: + tags: + - Rules + summary: List actions for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Actions list + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/RuleAction' + post: + tags: + - Rules + summary: Create action for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: + - ActionType + responses: + '201': + description: Action created + /api/rules/{id}/actions/{actionId}: + patch: + tags: + - Rules + summary: Update action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + responses: + '200': + description: Action updated + delete: + tags: + - Rules + summary: Soft delete action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Action deleted /api/specimen: get: tags: @@ -6716,6 +7066,81 @@ components: type: integer total_pages: type: integer + RuleDef: + type: object + properties: + RuleID: + type: integer + Name: + type: string + Description: + type: string + nullable: true + EventCode: + type: string + ScopeType: + type: string + enum: + - GLOBAL + - TESTSITE + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: + - 0 + - 1 + CreateDate: + type: string + format: date-time + nullable: true + StartDate: + type: string + format: date-time + nullable: true + EndDate: + type: string + format: date-time + nullable: true + RuleAction: + type: object + properties: + RuleActionID: + type: integer + RuleID: + type: integer + Seq: + type: integer + ActionType: + type: string + example: SET_RESULT + ActionParams: + type: string + description: JSON string parameters + nullable: true + CreateDate: + type: string + format: date-time + nullable: true + EndDate: + type: string + format: date-time + nullable: true + RuleWithActions: + allOf: + - $ref: '#/components/schemas/RuleDef' + - type: object + properties: + actions: + type: array + items: + $ref: '#/components/schemas/RuleAction' Contact: type: object properties: diff --git a/public/api-docs.yaml b/public/api-docs.yaml index e4b9f3e..1361325 100644 --- a/public/api-docs.yaml +++ b/public/api-docs.yaml @@ -55,6 +55,8 @@ tags: description: Laboratory equipment and instrument management - name: Users description: User management and administration + - name: Rules + description: Common rule engine (events, conditions, actions) components: securitySchemes: @@ -182,6 +184,14 @@ components: UserListResponse: $ref: './components/schemas/user.yaml#/UserListResponse' + # Rules schemas + RuleDef: + $ref: './components/schemas/rules.yaml#/RuleDef' + RuleAction: + $ref: './components/schemas/rules.yaml#/RuleAction' + RuleWithActions: + $ref: './components/schemas/rules.yaml#/RuleWithActions' + # Paths are in separate files in the paths/ directory # To view the complete API with all paths, use: api-docs.bundled.yaml # To rebuild the bundle after changes: python bundle-api-docs.py diff --git a/public/components/schemas/rules.yaml b/public/components/schemas/rules.yaml new file mode 100644 index 0000000..0574d23 --- /dev/null +++ b/public/components/schemas/rules.yaml @@ -0,0 +1,73 @@ +RuleDef: + type: object + properties: + RuleID: + type: integer + Name: + type: string + Description: + type: string + nullable: true + EventCode: + type: string + ScopeType: + type: string + enum: [GLOBAL, TESTSITE] + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: [0, 1] + CreateDate: + type: string + format: date-time + nullable: true + StartDate: + type: string + format: date-time + nullable: true + EndDate: + type: string + format: date-time + nullable: true + +RuleAction: + type: object + properties: + RuleActionID: + type: integer + RuleID: + type: integer + Seq: + type: integer + ActionType: + type: string + example: SET_RESULT + ActionParams: + type: string + description: JSON string parameters + nullable: true + CreateDate: + type: string + format: date-time + nullable: true + EndDate: + type: string + format: date-time + nullable: true + +RuleWithActions: + allOf: + - $ref: './rules.yaml#/RuleDef' + - type: object + properties: + actions: + type: array + items: + $ref: './rules.yaml#/RuleAction' diff --git a/public/paths/rules.yaml b/public/paths/rules.yaml new file mode 100644 index 0000000..014a9d5 --- /dev/null +++ b/public/paths/rules.yaml @@ -0,0 +1,316 @@ +/api/rules: + get: + tags: [Rules] + summary: List rules + security: + - bearerAuth: [] + parameters: + - name: EventCode + in: query + schema: + type: string + description: Filter by event code + - name: Active + in: query + schema: + type: integer + enum: [0, 1] + description: Filter by active flag + - name: ScopeType + in: query + schema: + type: string + enum: [GLOBAL, TESTSITE] + description: Filter by scope type + - name: TestSiteID + in: query + schema: + type: integer + description: Filter by TestSiteID (for TESTSITE scope) + - name: search + in: query + schema: + type: string + description: Search by rule name + responses: + '200': + description: List of rules + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '../components/schemas/rules.yaml#/RuleDef' + + post: + tags: [Rules] + summary: Create rule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: + type: string + Description: + type: string + EventCode: + type: string + example: ORDER_CREATED + ScopeType: + type: string + enum: [GLOBAL, TESTSITE] + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: [0, 1] + actions: + type: array + items: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: [Name, EventCode, ScopeType] + responses: + '201': + description: Rule created + +/api/rules/{id}: + get: + tags: [Rules] + summary: Get rule (with actions) + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule details + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + $ref: '../components/schemas/rules.yaml#/RuleWithActions' + '404': + description: Rule not found + + patch: + tags: [Rules] + summary: Update rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: { type: string } + Description: { type: string } + EventCode: { type: string } + ScopeType: { type: string, enum: [GLOBAL, TESTSITE] } + TestSiteID: { type: integer, nullable: true } + ConditionExpr: { type: string, nullable: true } + Priority: { type: integer } + Active: { type: integer, enum: [0, 1] } + responses: + '200': + description: Rule updated + '404': + description: Rule not found + + delete: + tags: [Rules] + summary: Soft delete rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule deleted + '404': + description: Rule not found + +/api/rules/validate: + post: + tags: [Rules] + summary: Validate/evaluate an expression + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expr: + type: string + context: + type: object + additionalProperties: true + required: [expr] + responses: + '200': + description: Validation result + +/api/rules/{id}/actions: + get: + tags: [Rules] + summary: List actions for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Actions list + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '../components/schemas/rules.yaml#/RuleAction' + + post: + tags: [Rules] + summary: Create action for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: [ActionType] + responses: + '201': + description: Action created + +/api/rules/{id}/actions/{actionId}: + patch: + tags: [Rules] + summary: Update action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: { type: integer } + ActionType: { type: string } + ActionParams: + oneOf: + - type: string + - type: object + responses: + '200': + description: Action updated + + delete: + tags: [Rules] + summary: Soft delete action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Action deleted diff --git a/tests/unit/Rules/RuleExpressionServiceTest.php b/tests/unit/Rules/RuleExpressionServiceTest.php new file mode 100644 index 0000000..b6c2a3c --- /dev/null +++ b/tests/unit/Rules/RuleExpressionServiceTest.php @@ -0,0 +1,24 @@ +evaluateBoolean('order["SiteID"] == 1', [ + 'order' => ['SiteID' => 1], + ]); + $this->assertTrue($ok); + + $no = $svc->evaluateBoolean('order["SiteID"] == 2', [ + 'order' => ['SiteID' => 1], + ]); + $this->assertFalse($no); + } +}