feat: add rules engine API and order-created hook

- Add /api/rules CRUD, nested actions, and expr validation

- Add rules migration, models, and RuleEngine/Expression services

- Run ORDER_CREATED rules after order create (non-blocking) and refresh tests

- Update OpenAPI tags/schemas/paths and bundled docs
This commit is contained in:
mahdahar 2026-03-12 06:34:56 +07:00
parent 911846592f
commit 88be3f3809
16 changed files with 2393 additions and 124 deletions

View File

@ -346,6 +346,21 @@ $routes->group('api', function ($routes) {
$routes->post('status', 'OrderTestController::updateStatus'); $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) // Demo/Test Routes (No Auth)
$routes->group('api/demo', function ($routes) { $routes->group('api/demo', function ($routes) {
$routes->post('order', 'Test\DemoOrderController::createDemoOrder'); $routes->post('order', 'Test\DemoOrderController::createDemoOrder');

View File

@ -7,6 +7,7 @@ use App\Libraries\ValueSet;
use App\Models\OrderTest\OrderTestModel; use App\Models\OrderTest\OrderTestModel;
use App\Models\Patient\PatientModel; use App\Models\Patient\PatientModel;
use App\Models\PatVisit\PatVisitModel; use App\Models\PatVisit\PatVisitModel;
use App\Services\RuleEngineService;
class OrderTestController extends Controller { class OrderTestController extends Controller {
use ResponseTrait; use ResponseTrait;
@ -147,12 +148,27 @@ class OrderTestController extends Controller {
} }
$orderID = $this->model->createOrder($input); $orderID = $this->model->createOrder($input);
// Fetch complete order details // Fetch complete order details
$order = $this->model->getOrder($orderID); $order = $this->model->getOrder($orderID);
$order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']); $order['Specimens'] = $this->getOrderSpecimens($order['InternalOID']);
$order['Tests'] = $this->getOrderTests($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([ return $this->respondCreated([
'status' => 'success', 'status' => 'success',
'message' => 'Order created successfully', 'message' => 'Order created successfully',

View File

@ -0,0 +1,231 @@
<?php
namespace App\Controllers\Rule;
use App\Controllers\BaseController;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
use App\Traits\ResponseTrait;
class RuleActionController extends BaseController
{
use ResponseTrait;
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
public function __construct()
{
$this->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());
}
}
}

View File

@ -0,0 +1,344 @@
<?php
namespace App\Controllers\Rule;
use App\Controllers\BaseController;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
use App\Services\RuleExpressionService;
use App\Traits\ResponseTrait;
class RuleController extends BaseController
{
use ResponseTrait;
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
public function __construct()
{
$this->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);
}
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateRules extends Migration
{
public function up()
{
// ruledef
$this->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');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models\Rule;
use App\Models\BaseModel;
class RuleActionModel extends BaseModel
{
protected $table = 'ruleaction';
protected $primaryKey = 'RuleActionID';
protected $allowedFields = [
'RuleID',
'Seq',
'ActionType',
'ActionParams',
'CreateDate',
'EndDate',
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = 'EndDate';
public function getActiveByRuleIDs(array $ruleIDs): array
{
if (empty($ruleIDs)) {
return [];
}
return $this->whereIn('RuleID', $ruleIDs)
->where('EndDate IS NULL')
->orderBy('RuleID', 'ASC')
->orderBy('Seq', 'ASC')
->orderBy('RuleActionID', 'ASC')
->findAll();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Models\Rule;
use App\Models\BaseModel;
class RuleDefModel extends BaseModel
{
protected $table = 'ruledef';
protected $primaryKey = 'RuleID';
protected $allowedFields = [
'Name',
'Description',
'EventCode',
'ScopeType',
'TestSiteID',
'ConditionExpr',
'Priority',
'Active',
'CreateDate',
'StartDate',
'EndDate',
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = 'StartDate';
protected $useSoftDeletes = true;
protected $deletedField = 'EndDate';
/**
* Fetch active rules for an event, optionally scoped.
*
* Scope behavior:
* - Always returns GLOBAL rules
* - If $testSiteID provided, also returns TESTSITE rules matching TestSiteID
*/
public function getActiveByEvent(string $eventCode, ?int $testSiteID = null): array
{
$builder = $this->where('EventCode', $eventCode)
->where('EndDate IS NULL')
->where('Active', 1)
->groupStart()
->where('ScopeType', 'GLOBAL');
if ($testSiteID !== null) {
$builder->orGroupStart()
->where('ScopeType', 'TESTSITE')
->where('TestSiteID', $testSiteID)
->groupEnd();
}
$builder->groupEnd();
return $builder
->orderBy('Priority', 'ASC')
->orderBy('RuleID', 'ASC')
->findAll();
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Rule\RuleActionModel;
use App\Models\Rule\RuleDefModel;
use App\Models\Test\TestDefSiteModel;
class RuleEngineService
{
protected RuleDefModel $ruleDefModel;
protected RuleActionModel $ruleActionModel;
protected RuleExpressionService $expr;
public function __construct()
{
$this->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');
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\ParsedExpression;
class RuleExpressionService
{
protected ExpressionLanguage $language;
/** @var array<string, ParsedExpression> */
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);
}
}

View File

@ -13,7 +13,8 @@
"php": "^8.1", "php": "^8.1",
"codeigniter4/framework": "^4.0", "codeigniter4/framework": "^4.0",
"firebase/php-jwt": "^6.11", "firebase/php-jwt": "^6.11",
"mossadal/math-parser": "^1.3" "mossadal/math-parser": "^1.3",
"symfony/expression-language": "^7.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.24", "fakerphp/faker": "^1.24",

709
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8fffd5cbb5e940a076c93e72a52f7734", "content-hash": "b111968eaeab80698adb5ca0eaeeb8c1",
"packages": [ "packages": [
{ {
"name": "codeigniter4/framework", "name": "codeigniter4/framework",
@ -255,6 +255,108 @@
}, },
"time": "2018-09-15T22:20:34+00:00" "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", "name": "psr/log",
"version": "3.0.2", "version": "3.0.2",
@ -304,6 +406,489 @@
"source": "https://github.com/php-fig/log/tree/3.0.2" "source": "https://github.com/php-fig/log/tree/3.0.2"
}, },
"time": "2024-09-11T13:17:53+00:00" "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": [ "packages-dev": [
@ -1088,59 +1673,6 @@
], ],
"time": "2025-06-20T11:29:11+00:00" "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", "name": "sebastian/cli-parser",
"version": "2.0.1", "version": "2.0.1",
@ -2057,73 +2589,6 @@
], ],
"time": "2023-02-07T11:34:05+00:00" "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", "name": "theseer/tokenizer",
"version": "1.2.3", "version": "1.2.3",
@ -2184,5 +2649,5 @@
"php": "^8.1" "php": "^8.1"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

View File

@ -53,6 +53,8 @@ tags:
description: Laboratory equipment and instrument management description: Laboratory equipment and instrument management
- name: Users - name: Users
description: User management and administration description: User management and administration
- name: Rules
description: Common rule engine (events, conditions, actions)
paths: paths:
/api/auth/login: /api/auth/login:
post: post:
@ -3056,6 +3058,354 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $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: /api/specimen:
get: get:
tags: tags:
@ -6716,6 +7066,81 @@ components:
type: integer type: integer
total_pages: total_pages:
type: integer 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: Contact:
type: object type: object
properties: properties:

View File

@ -55,6 +55,8 @@ tags:
description: Laboratory equipment and instrument management description: Laboratory equipment and instrument management
- name: Users - name: Users
description: User management and administration description: User management and administration
- name: Rules
description: Common rule engine (events, conditions, actions)
components: components:
securitySchemes: securitySchemes:
@ -182,6 +184,14 @@ components:
UserListResponse: UserListResponse:
$ref: './components/schemas/user.yaml#/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 # 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
# To rebuild the bundle after changes: python bundle-api-docs.py # To rebuild the bundle after changes: python bundle-api-docs.py

View File

@ -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'

316
public/paths/rules.yaml Normal file
View File

@ -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

View File

@ -0,0 +1,24 @@
<?php
namespace Tests\Unit\Rules;
use App\Services\RuleExpressionService;
use CodeIgniter\Test\CIUnitTestCase;
class RuleExpressionServiceTest extends CIUnitTestCase
{
public function testEvaluateBooleanWithArrayContext(): void
{
$svc = new RuleExpressionService();
$ok = $svc->evaluateBoolean('order["SiteID"] == 1', [
'order' => ['SiteID' => 1],
]);
$this->assertTrue($ok);
$no = $svc->evaluateBoolean('order["SiteID"] == 2', [
'order' => ['SiteID' => 1],
]);
$this->assertFalse($no);
}
}