diff --git a/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs b/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs index 1fd37054..c8d8ce8a 100644 --- a/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs +++ b/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs @@ -4,6 +4,6 @@ namespace YLErp.DBModels { Block = 1, Approval = 2, - Warning = 3 + ShowTip = 3 } } diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs index 59fe9099..61fcd263 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs @@ -5,7 +5,7 @@ namespace YLErp.Modules.RiskEngine { /// /// 规则编译结果的进程内缓存。 - /// 当前第一版按 RuleCode 缓存 Roslyn 编译后的可执行委托。 + /// 当前第一版按规则 Id 缓存 Roslyn 编译后的可执行委托。 /// public static class RuleCompiledCache { diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 5f003ff1..0d2f778f 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -32,8 +32,7 @@ namespace YLErp.Modules.RiskEngine /// - 可调试:生成的脚本代码可直接阅读和理解 /// /// 示例: - /// ConditionJson: { "conditions": [{"variableId":1,"operator":">","value":100000000}] } - /// 生成脚本:Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m + /// RuleExpr: Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m /// 编译结果:Func<RiskContext, bool>(内部通过 Roslyn 编译缓存) /// public static class RuleCompiler @@ -71,14 +70,14 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验并编译公式表达式。 - /// 当前 FormulaExp 要求是 Roslyn 可直接执行的 bool 表达式。 + /// 校验并编译规则表达式。 + /// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。 /// public static RuleCompileResult ValidateAndCompileFormula(string formulaExp) { if (string.IsNullOrWhiteSpace(formulaExp)) { - return RuleCompileResult.Fail("规则 FormulaExp 不能为空"); + return RuleCompileResult.Fail("规则 RuleExpr 不能为空"); } try @@ -101,100 +100,87 @@ namespace YLErp.Modules.RiskEngine { return RuleCompileResult.Fail("规则不能为空"); } - - if (string.IsNullOrWhiteSpace(rule.RuleCode)) + + if (rule.Id <= 0) { - return RuleCompileResult.Fail("规则 Code 不能为空"); + return RuleCompileResult.Fail("规则 Id 不能为空"); } var result = ValidateAndCompileFormula(rule.RuleExpr); if (!result.Success) { - return RuleCompileResult.Fail($"规则[{rule.RuleCode}]编译失败:{result.ErrorMessage}"); + return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}"); } rule.CompiledScript = result.CompiledScript; - RuleCompiledCache.Set(rule.RuleCode, result.CompiledScript); + RuleCompiledCache.Set(rule.Id.ToString(), result.CompiledScript); return result; } /// - /// 解析 ConditionJson,提取条件列表 + /// 解析 ConditionJson,提取条件列表。 + /// 要求 expression 明确描述实际取值路径。 + /// 当前主执行链不再使用该方法,仅作为早期方案保留。 /// - private static List<(long variableId, string op, object value, string variableType)> ParseConditionJson(string conditionJson) + // private static List<(string variableCode, string expression, string op, object value, string variableType)> ParseConditionJson(string conditionJson) + // { + // var result = new List<(string, string, string, object, string)>(); + // var jObj = JObject.Parse(formulaJson); + // var conditions = jObj["conditions"] as JArray; + + // if (conditions == null || !conditions.Any()) + // { + // throw new ArgumentException("ConditionJson 中缺少 conditions"); + // } + + // foreach (var cond in conditions) + // { + // string variableCode = cond["variableCode"]?.Value(); + // string expression = cond["expression"]?.Value(); + // string op = cond["operator"]?.Value(); + // object value = cond["value"]?.Value(); + // string variableType = cond["variableType"]?.Value() ?? "numeric"; + + // if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(op)) + // { + // throw new ArgumentException("条件中缺少 expression 或 operator"); + // } + + // result.Add((variableCode, expression, op, value, variableType)); + // } + + // return result; + // } + + /// + /// 根据条件列表生成 Roslyn C# 脚本代码。 + /// 多条件自动用 &&(AND)连接。 + /// 左值表达式统一基于 expression 构建。 + /// + private static string BuildScriptCode(List<(string variableCode, string expression, string op, object value, string variableType)> conditions) { - var result = new List<(long, string, object, string)>(); - var jObj = JObject.Parse(conditionJson); - var conditions = jObj["conditions"] as JArray; + var exprParts = new List(); - if (conditions == null || !conditions.Any()) + foreach (var (_, expression, op, value, variableType) in conditions) { - throw new ArgumentException("ConditionJson 中缺少 conditions"); + // 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量 + string leftExpr = BuildMemberAccessExpression(expression, variableType); + + // 格式化阈值 + string valueLiteral = FormatValueLiteral(value, variableType); + + // 格式化操作符 + string csharpOp = FormatOperator(op); + + // 生成单条件表达式 + string expr = $"{leftExpr} {csharpOp} {valueLiteral}"; + exprParts.Add(expr); } - foreach (var cond in conditions) - { - long variableId = cond["variableId"]?.Value() ?? 0; - string op = cond["operator"]?.Value(); - object value = cond["value"]?.Value(); - string variableType = cond["variableType"]?.Value() ?? "numeric"; - - if (variableId <= 0 || string.IsNullOrWhiteSpace(op)) - { - throw new ArgumentException("条件中缺少 variableId 或 operator"); - } - - result.Add((variableId, op, value, variableType)); - } - - return result; + // 多条件用 && 连接 + return string.Join(" && ", exprParts); } - /// - /// 根据条件列表生成 Roslyn C# 脚本代码 - /// 多条件自动用 &&(AND)连接 - /// TODO: 当前逻辑依赖 variableCode 的 "前缀.属性名" 格式, - /// 后续应改为通过 variableId 查变量池获取 VariableExpr,或直接使用 rule.RuleExpr - /// - //private static string BuildScriptCode(List<(long variableId, string op, object value, string variableType)> conditions) - //{ - // var exprParts = new List(); - - // foreach (var (variableId, op, value, variableType) in conditions) - // { - // // TODO: 通过 variableId 查变量池获取 VariableExpr(如 "trade.StockEqvNotional") - // // 当前暂用 variableId.ToString() 占位,后续接入变量池查询 - // string variableExpr = variableId.ToString(); - - // // 解析 variableExpr:前缀.属性名 - // var parts = variableExpr.Split('.'); - // if (parts.Length != 2) - // { - // throw new ArgumentException($"variableExpr 格式不正确:{variableExpr}(variableId={variableId})"); - // } - - // string prefix = parts[0].Trim().ToLower(); - // string propertyName = parts[1].Trim(); - - // if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName)) - // { - // throw new ArgumentException($"未知前缀:{prefix}"); - // } - // // 格式化阈值 - // string valueLiteral = FormatValueLiteral(value, variableType); - - // // 格式化操作符 - // string csharpOp = FormatOperator(op); - - // // 生成单条件表达式 - // string expr = $"{leftExpr} {csharpOp} {valueLiteral}"; - // exprParts.Add(expr); - // } - - // // 多条件用 && 连接 - // return string.Join(" && ", exprParts); - //} - /// /// 将前端传入的 expression 转成 Roslyn 可执行的成员访问表达式。 /// 例如 trade.StockEqvNotional 会转成 ((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional。 @@ -243,13 +229,15 @@ namespace YLErp.Modules.RiskEngine var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); // 编译(提前发现语法错误) + //后续编译需提供接口,返回前端编译信息,包含编译错误列表 var compilation = script.GetCompilation(); var diagnostics = compilation.GetDiagnostics(); var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); if (errors.Any()) { string errorMsg = string.Join("; ", errors.Select(e => e.GetMessage())); - throw new InvalidOperationException($"脚本编译失败:{errorMsg}\n脚本代码:{scriptCode}"); + _logger.Error($"规则编译失败:{errorMsg}\n脚本代码:{scriptCode}"); + throw new InvalidOperationException($"脚本编译失败:{errorMsg}"); } // 生成可调用委托 diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 0f763cf9..b357f444 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -17,28 +17,28 @@ using YLErp.Model; 【当前实现口径】(以本注释和实际代码为准) 核心概念: - - RiskRule(风控规则):规则定义本体,当前重点字段包括 RuleCode、RuleName、 - FormulaText、FormulaExp、Version、CompiledScript。 + - RiskRule(风控规则):规则定义本体,当前重点字段包括 Id、RuleName、 + RuleText、RuleExpr、Version、CompiledScript。 - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - - FormulaExp:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, 例如直接访问 DataMap["trade"] 后做数值或日期比较。 - - FormulaJson:当前已在规则定义和样例代码中停用,不参与主执行链路。 + - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。 - - RuleCompiledCache:进程内编译结果缓存,按 RuleCode 缓存 Func。 + - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 当前编译流程: - 1. 外部准备好规则对象,FormulaExp 中直接写最终可执行表达式 + 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) - 3. 内部使用 Roslyn 编译 FormulaExp,生成 Func + 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 5. 执行阶段优先从 RuleCompiledCache 取委托执行 当前执行流程(EvaluateRisk): - 1. 加载示例规则列表和示例应用列表 + 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) - 4. 根据命中的 RuleCode 找到对应规则 + 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult @@ -50,11 +50,11 @@ using YLErp.Model; - 某维度留空:视为该维度不限制 【与早期方案的主要差异】 - - 当前不是 Content 解析或表达式树主导,而是 FormulaExp 直编译主导 + - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 - - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 FormulaExp 直接表达 + - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 RuleExpr 直接表达 【当前代码结构】 风控引擎层:YLErpDAL/Modules/RiskEngine/ @@ -75,7 +75,7 @@ using YLErp.Model; ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler 2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段 - 3. FormulaExp 直编译方案已接入,支持数值比较和日期比较 + 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND 6. RuleCompiler 已支持校验 + 编译 + 写缓存 @@ -84,9 +84,9 @@ using YLErp.Model; 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 - - 由数据库或前端提交替换当前 LoadRules / LoadApplications 示例数据 + - 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源 ⬜ 2. 增加接口层 - - 提供前端提交 FormulaExp 后的校验接口 + - 提供前端提交 RuleExpr 后的校验接口 - 提供规则保存 / 发布后预编译并写缓存的入口 ⬜ 3. 启动预热与缓存刷新 - 服务启动时批量加载有效规则并预编译 @@ -96,12 +96,12 @@ using YLErp.Model; - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” ⬜ 5. 补测试样例 - 数值比较、日期比较、维度过滤、非法公式、边界值 - ⬜ 6. 完善 FormulaExp 配套能力 + ⬜ 6. 完善 RuleExpr 配套能力 - 提供前端可用的表达式编辑、校验与错误提示 - 约束可用变量、类型转换和脚本安全边界 【注意事项】 - 1. 当前 FormulaExp 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 + 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 @@ -168,7 +168,7 @@ namespace YLErp.Modules.RiskEngine // Step 2: 先按 Application 过滤 Active 状态和 TriggerPoint 匹配的应用 // ============================================================ var activeApps = applications - .Where(a => a.Status == RiskRuleApplicationStatusEnum.Active) + .Where(a => a.Status == RiskRuleStatus.Active) .ToList(); var apps = activeApps .Where(a => !string.IsNullOrEmpty(a.TriggerPoints)) @@ -196,113 +196,139 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[风控引擎] 匹配 TriggerPoint 的应用数: {matchedApplications.Count}"); // ============================================================ - // Step 3: 遍历匹配的应用,通过 RuleCode 关联规则并执行预编译委托 + // Step 3: 遍历匹配的应用,通过 RuleIds 关联规则并执行预编译委托 // ============================================================ foreach (var application in matchedApplications) { // ------------------------------------------------------------ - // 3.1 通过 RuleCode 关联规则定义 + // 3.1 通过 RuleIds 关联规则定义 // ------------------------------------------------------------ - var rule = rules.FirstOrDefault(r => r.RuleCode == application.RuleCode); - if (rule == null) + var applicationRuleIds = ParseRuleIds(application.RuleIds); + if (!applicationRuleIds.Any()) { - _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleCode: {application.RuleCode}"); - result.HasError = true; - result.ErrorMessage += $"未找到规则定义[{application.RuleCode}];"; + _logger.Info($"[风控引擎] 应用未配置有效规则 - RuleIds: {application.RuleIds}"); continue; } - // ------------------------------------------------------------ - // 3.2 优先从编译缓存读取规则委托 - // ------------------------------------------------------------ - if (!RuleCompiledCache.TryGet(rule.RuleCode, out var compiledScript)) - { - _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleCode: {rule.RuleCode}"); + var applicationRules = rules + .Where(r => applicationRuleIds.Contains(r.Id) && r.Id == 3) + .ToList(); - var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) + var missingRuleIds = applicationRuleIds + .Where(ruleId => applicationRules.All(r => r.Id != ruleId)) + .ToList(); + + foreach (var missingRuleId in missingRuleIds) + { + _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {missingRuleId}, ApplicationRuleIds: {application.RuleIds}"); + } + + foreach (var rule in applicationRules) + { + var ruleId = rule.Id.ToString(); + + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) { - _logger.Info($"[风控引擎] 规则编译失败 - RuleCode: {rule.RuleCode}, Error: {compileResult.ErrorMessage}"); - result.HasError = true; - result.ErrorMessage += $"规则[{rule.RuleCode}]编译失败:{compileResult.ErrorMessage};"; + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + continue; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ + bool triggered = false; + try + { + triggered = compiledScript(context); + _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); continue; } - compiledScript = compileResult.CompiledScript; - } - - // ------------------------------------------------------------ - // 3.3 执行预编译委托 - // ------------------------------------------------------------ - bool triggered = false; - try - { - triggered = compiledScript(context); - _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); - } - catch (Exception ex) - { - _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); - result.HasError = true; - result.ErrorMessage += $"规则[{rule.Id}]执行异常:{ex.Message};"; - continue; - } - - // ------------------------------------------------------------ - // 3.4 命中后按 Application 的控制策略聚合结果 - // ------------------------------------------------------------ - if (triggered) - { - _logger.Info($"[风控引擎] 规则触发 - RuleCode: {rule.RuleCode}, Strategy: {application.ControlStrategy}"); - - switch (application.ControlStrategy) + // ------------------------------------------------------------ + // 3.4 命中后按 Application 的控制策略聚合结果 + // ------------------------------------------------------------ + if (triggered) { - case RiskControlStrategyEnum.Block: - result.Blocked = true; - result.Passed = false; - result.TriggeredRules.Add(new TriggeredRuleInfo - { - RuleName = rule.RuleName, - ControlStrategy = "禁止", - RuleText = rule.RuleText, - Message = $"规则[{rule.RuleName}]触发:禁止" - }); - break; + _logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}"); - case RiskControlStrategyEnum.Approval: - result.NeedApproval = true; - result.Passed = false; - result.TriggeredRules.Add(new TriggeredRuleInfo - { - RuleName = rule.RuleName, - ControlStrategy = "审批", - RuleText = rule.RuleText, - Message = $"规则[{rule.RuleName}]触发:需审批" - }); - break; + switch (application.ControlStrategy) + { + case RiskControlStrategy.Block: + result.Blocked = true; + result.Passed = false; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = rule.Id.ToString(), + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.Block, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:禁止" + }); + break; - case RiskControlStrategyEnum.Warning: - result.Warnings.Add($"规则[{rule.RuleName}]触发:提示 - {rule.RuleText}"); - break; + case RiskControlStrategy.Approval: + result.NeedApproval = true; + result.Passed = false; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = ruleId, + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.Approval, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:需审批" + }); + break; - default: - _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); - break; + case RiskControlStrategy.ShowTip: + result.ShowTip = true; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = ruleId, + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.ShowTip, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:提示" + }); + break; + + default: + _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); + break; + } + } + else + { + _logger.Info($"[风控引擎] 规则未触发 - RuleId: {rule.Id}, TriggerPoint: {triggerPoint}"); } - } - else - { - _logger.Info($"[风控引擎] 规则未触发 - RuleCode: {rule.RuleCode}, TriggerPoint: {triggerPoint}"); } } // ============================================================ // Step 4: 聚合最终结果 // ============================================================ - if (!result.Blocked && !result.NeedApproval && result.Warnings.Count == 0 && !result.HasError) + if (!result.Blocked && !result.NeedApproval) { result.Passed = true; } + else + { + result.Passed = false; + } _logger.Info($"[风控引擎] EvaluateRisk 完成 - TradeId: {context?.TradeId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, TriggeredRules: {result.TriggeredRules.Count}"); } @@ -312,11 +338,10 @@ namespace YLErp.Modules.RiskEngine result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { - RuleCode = "ENGINE_ERROR", + RuleId = "ENGINE_ERROR", RuleName = "风控引擎执行异常", - ControlStrategy = "审批", RuleText = ex.Message, - Message = $"风控引擎异常:{ex.Message},需审批通过" + Message = $"风控引擎异常:{ex.Message}" }); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } @@ -325,74 +350,75 @@ namespace YLErp.Modules.RiskEngine } /// - /// 加载规则列表(TODO: 后续接入真实内存缓存和数据库查询) - /// 当前返回示例规则用于验证流程 + /// 加载规则列表(TODO: 后续接入真实内存缓存) + /// 当前从数据库读取规则用于验证流程 /// private List LoadRules() { - //名义本金:Convert.ToDecimal(((YLErp.DBModels.trade)DataMap[\"trade\"]).StockEqvNotional) - //挂钩标的到期日:((YLErp.DBModels.trade)DataMap[\"trade\"]).MaturityDate - //合约到期日:((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate + var rules = DbContext.glms_risk_rule + .AsNoTracking() + .Where(r => r.Status != RiskRuleStatus.Deleted) + .OrderByDescending(r => r.UpdateDate ?? r.OptDate) + .Select(r => new RiskRule + { + Id = r.id, + RuleName = r.RuleName, + RuleText = r.RuleText, + ConditionJson = r.ConditionJson, + RuleExpr = r.RuleExpr, + Version = r.Version, + IsDeleted = r.Status == RiskRuleStatus.Deleted, + OptId = r.OptId ?? 0, + OptName = r.OptName, + OptDate = r.OptDate ?? DateTime.MinValue, + UpdateOptId = r.UpdateOptId ?? 0, + UpdateOptName = r.UpdateOptName, + UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue + }) + .ToList(); - // 示例规则1:先保留一个最小可运行样例,只验证名义本金阈值判断 - var Rule1 = new RiskRule + foreach (var rule in rules) { - Id = 103, - RuleName = "测试规则:名义本金超过1亿", - RuleText = "名义本金>1亿", - // FormulaJson = "{ \"conditions\": [" + - // "{\"variableCode\":\"StockEqvNotional\",\"expression\":\"trade.StockEqvNotional\",\"operator\":\">\",\"value\":100000000,\"variableType\":\"number\"}" + - // "] }", - RuleExpr = "Convert.ToDecimal(((YLErp.DBModels.trade)DataMap[\"trade\"]).StockEqvNotional) > 100000000m", - Version = 1 - }; + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + } + } - // 示例规则2:挂钩标的到期日小于合约到期日 - var Rule2 = new RiskRule - { - Id = 104, - RuleCode = "RISK-20260623-0004", - RuleName = "挂钩标的到期日小于合约到期日", - RuleText = "挂钩标的到期日<合约到期日", - //FormulaJson = "{ \"conditions\": [{\"variableCode\":\"MaturityDate\",\"expression\":\"trade.MaturityDate\",\"operator\":\"<\",\"value\":\"trade.ExerciseDate\",\"variableType\":\"date\"}] }", - RuleExpr = "((YLErp.DBModels.trade)DataMap[\"trade\"]).MaturityDate < ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate", - Version = 1 - }; - - // 当前样例规则在加载阶段预编译并写入缓存,便于执行阶段直接取用 - RuleCompiler.Compile(Rule1); - RuleCompiler.Compile(Rule2); - - return new List { Rule1, Rule2 }; + return rules; } /// /// 加载application - /// 当前返回示例规则用于验证流程 + /// 当前从数据库读取应用配置用于验证流程 /// private List LoadApplications() { - var application1 = new RiskRuleApplication - { - Id = 1, - RuleCode = "RISK-20260622-0003", - Status = RiskRuleApplicationStatusEnum.Active, - ControlStrategy = RiskControlStrategyEnum.Block, - TriggerPoints = "BOOK_CONFIRM", - ScopeIsGlobal = true, - }; - - var application2 = new RiskRuleApplication - { - Id = 2, - RuleCode = "RISK-20260623-0004", - Status = RiskRuleApplicationStatusEnum.Active, - ControlStrategy = RiskControlStrategyEnum.Block, - TriggerPoints = "BOOK_CLICK,BOOK_CONFIRM", - ScopeIsGlobal = false, - ScopeTradeTypes = "收益互换", - }; - - return new List { application1, application2 }; + return DbContext.glms_risk_rule_application + .AsNoTracking() + .Where(a => a.Status != RiskRuleStatus.Deleted) + .OrderByDescending(a => a.UpdateDate ?? a.OptDate) + .Select(a => new RiskRuleApplication + { + Id = a.id, + RuleIds = a.RuleIds, + Status = a.Status, + ControlStrategy = a.ControlStrategy, + TriggerPoints = a.TriggerPoints, + ScopeIsGlobal = a.ScopeIsGlobal, + ScopeAssetBookIds = a.ScopeAssetBookIds, + ScopeClientIds = a.ScopeClientIds, + ScopeUnderlyingTypes = a.ScopeUnderlyingTypes, + ScopeTradeTypes = a.ScopeTradeTypes, + Version = a.Version, + OptId = a.OptId ?? 0, + OptName = a.OptName, + OptDate = a.OptDate ?? DateTime.MinValue, + UpdateOptId = a.UpdateOptId ?? 0, + UpdateOptName = a.UpdateOptName, + UpdateDate = a.UpdateDate ?? a.OptDate ?? DateTime.MinValue + }) + .ToList(); } /// @@ -431,6 +457,29 @@ namespace YLErp.Modules.RiskEngine && tradeTypeMatched; } + /// + /// 解析应用配置中的规则ID列表。 + /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 + /// + private List ParseRuleIds(string ruleIds) + { + if (string.IsNullOrWhiteSpace(ruleIds)) + { + return new List(); + } + + var ids = new List(); + foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (long.TryParse(part.Trim(), out long id)) + { + ids.Add(id); + } + } + + return ids; + } + /// /// 判断某个范围字段是否为空。 /// 为空表示该维度不限制。 diff --git a/YLErpDAL/Modules/RiskEngine/RiskResult.cs b/YLErpDAL/Modules/RiskEngine/RiskResult.cs index 92b5119d..69edbd57 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskResult.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskResult.cs @@ -23,24 +23,14 @@ namespace YLErp.Modules.RiskEngine public bool NeedApproval { get; set; } /// - /// 是否存在执行异常 + /// 是否存在提示 /// - public bool HasError { get; set; } - - /// - /// 详细错误信息 - /// - public string ErrorMessage { get; set; } + public bool ShowTip { get; set; } /// /// 触发的规则列表 /// public List TriggeredRules { get; set; } = new List(); - - /// - /// 提示类警告信息 - /// - public List Warnings { get; set; } = new List(); } /// @@ -48,9 +38,9 @@ namespace YLErp.Modules.RiskEngine /// public class TriggeredRuleInfo { - public string RuleCode { get; set; } + public string RuleId { get; set; } public string RuleName { get; set; } - public string ControlStrategy { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } public string RuleText { get; set; } public string Message { get; set; } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs index b01c9f1a..5558c2b4 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -13,7 +13,6 @@ namespace YLErp.Modules.RiskEngine public int Id { get; set; } public string RuleName { get; set; } public string RuleText { get; set; } - public string RuleCode { get; set; } /// /// 【核心】条件JSON(结构化模式的条件列表存储),如 diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs index 2a554d1e..7b741252 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs @@ -1,4 +1,5 @@ using System; +using YLErp.DBModels; namespace YLErp.Modules.RiskEngine { @@ -18,55 +19,6 @@ namespace YLErp.Modules.RiskEngine /// /// 当前文件先定义第一版最小模型,用于支撑基础流程跑通和示例数据加载。 /// - /// - /// 规则应用状态枚举。 - /// 用于描述一条规则应用配置当前是否参与风控执行。 - /// - public enum RiskRuleApplicationStatusEnum - { - /// - /// 已生效。 - /// 当前应用配置可正常参与风控判断。 - /// - Active = 1, - - /// - /// 已停用。 - /// 当前应用配置暂不参与风控判断,但配置仍被保留。 - /// - Disabled = 2, - - /// - /// 已删除。 - /// 当前应用配置逻辑上已删除,通常不再参与任何执行。 - /// - Deleted = 3 - } - - /// - /// 风控控制策略枚举。 - /// 用于描述规则命中后的处理方式。 - /// - public enum RiskControlStrategyEnum - { - /// - /// 禁止。 - /// 规则命中后直接拦截,不允许继续后续流程。 - /// - Block = 1, - - /// - /// 审批。 - /// 规则命中后不直接放行,需要进入审批流程。 - /// - Approval = 2, - - /// - /// 提示。 - /// 规则命中后只给出风险提示,不阻断流程。 - /// - Warning = 3 - } public class RiskRuleApplication { /// @@ -76,11 +28,11 @@ namespace YLErp.Modules.RiskEngine public int Id { get; set; } /// - /// 关联的规则编码。 - /// 通过该字段与 RiskRule.RuleCode 关联,表示当前应用配置对应哪一条规则定义。 - /// 第一版先用字符串编码关联,后续如有需要也可以扩展为 RuleId。 + /// 关联的规则ID列表。 + /// 通过该字段与 RiskRule.Id 关联,多个规则ID使用逗号分隔。 /// - public string RuleCode { get; set; } + + public string RuleIds { get; set; } /// /// 应用状态。 @@ -90,7 +42,7 @@ namespace YLErp.Modules.RiskEngine /// 3 = Deleted(已删除) /// 在实际执行时,通常只加载 Active 状态的应用配置。 /// - public RiskRuleApplicationStatusEnum Status { get; set; } + public RiskRuleStatus Status { get; set; } /// /// 校验策略。 @@ -100,7 +52,7 @@ namespace YLErp.Modules.RiskEngine /// 3 = 提示 /// 当规则命中时,由该字段决定最终处理方式。 /// - public RiskControlStrategyEnum ControlStrategy { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } /// /// 触发时点。 @@ -149,6 +101,8 @@ namespace YLErp.Modules.RiskEngine /// public string ScopeTradeTypes { get; set; } + public int Version { get; set; } + /// /// 创建人ID。 /// 记录是谁创建了这条应用配置。 @@ -185,4 +139,4 @@ namespace YLErp.Modules.RiskEngine /// public DateTime UpdateDate { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index fa5132ab..27f4a175 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4332,7 +4332,12 @@ namespace YLErp.Modules.RiskModule public bool QuotaCheck(ref TradeOpenResult res) { - if (res == null && res.Trade == null) + return QuotaCheck(ref res, false); + } + + public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning) + { + if (res == null || res.Trade == null) { throw new ArgumentNullException(nameof(res)); } @@ -4349,6 +4354,12 @@ namespace YLErp.Modules.RiskModule res.TrialDataId = quotaObj.id; return false; } + //成功状态下,如果触发TIp检查,提示用户 + if (!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)) + { + res.TrialDataId = quotaObj.id; + res.TipMsg = quotaObj.RiskWarningDetails; + } return true; } var lastTrial = trialService.QueryLastQuotaTrial(tradeId, true); @@ -4357,8 +4368,17 @@ namespace YLErp.Modules.RiskModule new TradeRiskCheckLogService(UserInfo).AddLog(quotaObj); } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; - res.RetCode = TradeOpenRetCode.QuotaTrialError; res.TrialDataId = quotaObj.id; + res.ErrorMsg = quotaObj.RiskWarningDetails; + var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning + && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); + //需清除ErrorMsg,不然外部调用会认为失败 + if (ignoreRiskWarning && isRiskApprovalWarning) + { + res.ErrorMsg = string.Empty; + return true; + } + res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; return false; } @@ -4940,29 +4960,48 @@ namespace YLErp.Modules.RiskModule }; // 构造 DataMap(第一版只塞 trade 对象,后续按需扩展) // 当前先用 demo 值验证“名义本金超过1亿”的规则是否能命中 - tradeObj.StockEqvNotional = 1000; + tradeObj.StockEqvNotional = 1000000000; tradeObj.TradeType = "收益互换"; tradeObj.MaturityDate = new DateTime(2026, 6, 19); tradeObj.ExerciseDate = new DateTime(2026, 6, 21); riskContext.DataMap["trade"] = tradeObj; var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM"); - _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}"); + _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}"); // 按策略映射回 QuotaTrialStatusEnum if (riskResult.Blocked) { result.TrialStatus = QuotaTrialStatusEnum.Error; result.RiskWarningDetails += "[风控引擎] 规则触发:禁止\n"; + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Block)) + { + result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + } } - else if (riskResult.NeedApproval) + if (riskResult.NeedApproval) { - result.TrialStatus = QuotaTrialStatusEnum.Warning; + if (result.TrialStatus != QuotaTrialStatusEnum.Error) + { + result.TrialStatus = QuotaTrialStatusEnum.Warning; + } result.RiskWarningDetails += "[风控引擎] 规则触发:需审批\n"; + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval)) + { + result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + } } - else if (riskResult.Warnings != null && riskResult.Warnings.Count > 0) + if (riskResult.ShowTip) { - result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join(";", riskResult.Warnings) + "\n"; + var warningMessages = riskResult.TriggeredRules + .Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip) + .Select(r => r.Message) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + if (warningMessages.Count > 0) + { + result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join(";", warningMessages) + "\n"; + } } } catch (Exception ex) diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 6055e15c..53ac78c3 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using MoreLinq; using OfficeOpenXml; using Snowflake.Core; @@ -146,6 +146,39 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + public void AddWarningDecisionLog(int quotaTrialId, string decision) + { + var quotaTrial = DbContext.quotaTrial.FirstOrDefault(x => x.id == quotaTrialId); + if (quotaTrial == null) + { + return; + } + var td = DbContext.trade.FirstOrDefault(x => x.id == quotaTrial.TradeId); + trade_risk_check_log log = new trade_risk_check_log(); + if (td != null) + { + var client = DataCacheProvider.GetClientDataSource().GetData(td.ClientId); + log.client_number = client?.Number; + } + var worker = new IdWorker(1, 1); + log.id = worker.NextId(); + log.trial_result = (int)quotaTrial.TrialStatus; + log.client_name = quotaTrial.ClientName; + log.trader = td?.TraderName; + log.trade_number = quotaTrial.TradeNumber; + log.risk_warning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) + ? $"[风控预警处理]{decision}" + : quotaTrial.RiskWarningDetails + Environment.NewLine + $"[风控预警处理]{decision}"; + log.limit_warning = quotaTrial.QuotaCheckDetails; + log.remark = string.IsNullOrWhiteSpace(quotaTrial.Remark) + ? $"风控预警处理结果:{decision}" + : quotaTrial.Remark + ";风控预警处理结果:" + decision; + log.create_user = UserId; + log.create_time = DateTime.Now; + DbContext.trade_risk_check_log.Add(log); + DbContext.SaveChanges(); + } + /// /// 回退重置 /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index f6f74962..4f427cf4 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Office2010.Excel; using OfficeOpenXml.Drawing; using Org.BouncyCastle.Ocsp; @@ -46,7 +46,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// 2021-02-04改造:批量确认因为耗时太长,用户多页面(交易列表,今日交易)并发确认导致生成多条期权费资金记录 /// - public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false) + public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false) { if (tradeids is null || !tradeids.Any(n => n > 0)) { @@ -76,6 +76,7 @@ namespace YLErp.Modules.TradeModule.DealModule var hasTradeProcess = HasTradeProcess(); var sbError = new StringBuilder(); + var sbTip = new StringBuilder(); var result = new TradeConfirmResultModel { confirmedTradeIds = new List(), @@ -99,7 +100,8 @@ namespace YLErp.Modules.TradeModule.DealModule isSkipApproval: isSkipApproval, confirmedTradeIds: result.confirmedTradeIds, changeConfirmPaths: result.changeConfirmPaths, - continueQuotaCheck: idCache.Contains(pid)); + continueQuotaCheck: idCache.Contains(pid), + ignoreRiskWarning: ignoreRiskWarning); if (pid > 0) { idCache.Add(pid); @@ -114,7 +116,8 @@ namespace YLErp.Modules.TradeModule.DealModule result.LackOfMoney = true; break; } - if (temp.RetCode == TradeOpenRetCode.QuotaTrialError || temp.RetCode == TradeOpenRetCode.FundStatus) + //此处LackOfMoney仅仅代表需要前端二次处理,不代表资金缺乏,延用老逻辑 + if (temp.RetCode == TradeOpenRetCode.QuotaTrialError || temp.RetCode == TradeOpenRetCode.FundStatus || temp.RetCode == TradeOpenRetCode.RiskWarning) { result.LackOfMoney = true; result.TrialDataId = temp.TrialDataId; @@ -128,6 +131,11 @@ namespace YLErp.Modules.TradeModule.DealModule sbError.AppendLine(temp.ErrorMsg); } + if (!string.IsNullOrEmpty(temp.TipMsg)) + { + sbTip.AppendLine(temp.TipMsg); + } + Helpers.LockHelper.Release(lockKeyId); } } @@ -135,6 +143,7 @@ namespace YLErp.Modules.TradeModule.DealModule DbContext.SaveChanges(); result.errorMsg = sbError.ToString(); + result.tipMsg = sbTip.ToString(); return result; } @@ -152,7 +161,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// private TradeOpenResult ConfirmTrade(int tid, bool isBatch, bool hasTradeProcess, - bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false) + bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false) { var td = DbContext.trade.FirstOrDefault(t => t.id == tid); var result = new TradeOpenResult(td ?? new trade()); @@ -207,7 +216,7 @@ namespace YLErp.Modules.TradeModule.DealModule } var trialService = new QuotaMonitorService(this); - if (!trialService.QuotaCheck(ref result)) + if (!trialService.QuotaCheck(ref result, ignoreRiskWarning)) { return result; } @@ -223,6 +232,10 @@ namespace YLErp.Modules.TradeModule.DealModule history = "特权账户批量确认自动" + history; } } + else if (ignoreRiskWarning) + { + history = "风控预警确认-" + history; + } //进入交易审批流程 if (hasTradeProcess && !isSkipApproval) { @@ -609,6 +622,7 @@ namespace YLErp.Modules.TradeModule.DealModule public List confirmedTradeIds; public List changeConfirmPaths; public string errorMsg; + public string tipMsg; public string type; } } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 0e354bf0..448967ec 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.Model; @@ -792,6 +792,10 @@ namespace YLErp.Modules.TradeModule.DealModule /// 错误信息 /// public string ErrorMsg; + /// + /// 提示信息 + /// + public string TipMsg; /// /// 变更确认书路径 @@ -831,6 +835,10 @@ namespace YLErp.Modules.TradeModule.DealModule /// FundStatus, /// + /// 风控预警二次确认 + /// + RiskWarning, + /// /// 无审核权限 /// NoRight diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 5571b14d..993be3a9 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using Org.BouncyCastle.Ocsp; using System; using System.Drawing; @@ -25,6 +25,8 @@ namespace YLErp.BLL /// public const string LackOfMoney = "LackOfMoney"; + public const string RiskWarningConfirm = "RiskWarningConfirm"; + public const string IgnoreBreak = "IgnoreBreak"; /// diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 428a4c66..d51e2550 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2456,18 +2456,30 @@ namespace YLErp.Web.Controllers } var config = valuedateBLL.SystemDate; var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; + var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; var isSkipApproval = false; if (CurUser.交易管理_特批批量确认 && isSkipCheck) { ignoreMoneyCheck = true; isSkipApproval = true; } - var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval); + var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning); //如果客户缺少资金而操作者有交易特批权限 - if (!ignoreMoneyCheck && result.LackOfMoney) + if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + if (result.type == TradeOpenRetCode.RiskWarning.ToString()) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + if (result.type == TradeOpenRetCode.FundStatus.ToString()) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + if (result.type == TradeOpenRetCode.LackOfMoney.ToString() || ((PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1) && string.IsNullOrWhiteSpace(result.type))) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } } //生成交易确认书 @@ -2475,7 +2487,7 @@ namespace YLErp.Web.Controllers { new TradeContractGenerateService(CurUser).GenerateContractsAsync(result.confirmedTradeIds, "确认书"); } - + //有ErrorMsg则认为失败 if (!string.IsNullOrEmpty(result.errorMsg)) { if (result.changeConfirmPaths?.Count > 0) @@ -2486,11 +2498,12 @@ namespace YLErp.Web.Controllers } else { + var successMsg = string.IsNullOrWhiteSpace(result.tipMsg) ? "操作完成" : result.tipMsg; if (result.changeConfirmPaths?.Count > 0) { - return JsonSuccess("操作完成", new { generateChangeSuccess = true, url = result.changeConfirmPaths }); + return JsonSuccess(successMsg, new { generateChangeSuccess = true, url = result.changeConfirmPaths }); } - return JsonSuccess("操作完成"); + return JsonSuccess(successMsg); } } @@ -8396,6 +8409,20 @@ namespace YLErp.Web.Controllers return JsonSuccess(); } + public JsonResult SaveRiskWarningDecisionLog(int quotaTrialId, string decision) + { + try + { + new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(quotaTrialId, decision); + } + catch (Exception ex) + { + LogFactory.GetLogger("SaveRiskWarningDecisionLog").Error(ex); + return JsonError("保存风控预警处理结果失败!"); + } + return JsonSuccess(); + } + [MyAuthorize("风险控制-接口审批列表")] public ActionResult InterfaceApproval() { diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js index f95b5078..c1943d3b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js @@ -1,4 +1,4 @@ -const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); +const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); function deletetrade(id) { //无效化 main.confirm(page.ConfirmInfo, function () { @@ -85,6 +85,34 @@ var confirmFunc = function (id, additionalProcessing) { } if (data.obj && data.obj.proccessType === "AdditionalProcessing") { if (data.obj.TrialDataId) { + var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm"; + var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + var saveQuotaTrial = function (obj) { + var saveSuccess = true; + if (obj.IsTF && (!obj.Data.Remark || obj.Data.Remark.length <= 0)) { + main.message("必须填写说明内容,才可以录入交易"); + return false; + } + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + } + return saveSuccess; + }; + var saveRiskWarningDecisionLog = function (quotaTrialId, decision) { + var saveSuccess = true; + main.post("/trade/SaveRiskWarningDecisionLog", { quotaTrialId: quotaTrialId, decision: decision }, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + return saveSuccess; + }; var layerSetting = { type: 2, title: "提示", @@ -94,31 +122,23 @@ var confirmFunc = function (id, additionalProcessing) { content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, yes: function (index) { var obj = window["layui-layer-iframe" + index].page; - if (obj.IsTF) { - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); + if (!saveQuotaTrial(obj)) { + return; + } + if (isRiskWarningConfirm) { + if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { return; } - main.message("必须填写说明内容,才可以录入交易"); - } else { - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - } - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); } + layer.close(layerIndex); + confirmFunc(id, additionalProcessingType); }, cancel: function () { + var iframeWindow = window["layui-layer-iframe" + this.index]; + var pageObj = iframeWindow && iframeWindow.page; + if (isRiskWarningConfirm && pageObj && pageObj.Data && pageObj.Data.id) { + saveRiskWarningDecisionLog(pageObj.Data.id, "取消不通过"); + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -128,8 +148,10 @@ var confirmFunc = function (id, additionalProcessing) { if (data.obj.type === "LackOfMoney") { layerSetting.btn = [page.buttonStr, '取消']; } - // 只展示“资金状况”的情况 - if (data.obj.typecode === "FundStatus") { + else if (isRiskWarningConfirm) { + layerSetting.btn = ["交易特批", '取消']; + } + else if (data.obj.typecode === "FundStatus") { layerSetting.btn = ["确认", '取消']; } @@ -167,6 +189,14 @@ var confirmFunc = function (id, additionalProcessing) { }); }); } + if (data.msg && data.msg !== "操作完成") { + main.alert(data.msg, function () { + if (blClose) { + closetradeWindow(); + } + }); + return; + } if (blClose) { closetradeWindow(); }