clqms-be/app/Services/RuleEngineService.php
mahdahar 88be3f3809 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
2026-03-12 06:34:56 +07:00

146 lines
4.6 KiB
PHP

<?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');
}
}
}