From f78a5bdc2a04b5c76e546011e8b79d4397938491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Thu, 2 Jul 2026 15:54:33 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Dto/QueryRiskAuditLogReq.cs | 3 +++ YLErpDAL/Modules/RiskEngine/RiskRuleService.cs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs index 690712fa..6e77c983 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs @@ -6,7 +6,10 @@ namespace YLErp.Modules.RiskEngine.Dto public class QueryRiskAuditLogReq : BaseSearchReq { public string OperationType { get; set; } + public string OperationTypes { get; set; } public string TargetType { get; set; } + public string TargetName { get; set; } + public string OptName { get; set; } public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } public string Keyword { get; set; } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 7e79f074..08421a07 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1461,11 +1461,27 @@ namespace YLErp.Modules.RiskEngine query = query.Where(l => l.OperationType == req.OperationType); } + if (!string.IsNullOrWhiteSpace(req.OperationTypes)) + { + var types = req.OperationTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + query = query.Where(l => types.Contains(l.OperationType)); + } + if (!string.IsNullOrWhiteSpace(req.TargetType)) { query = query.Where(l => l.TargetType == req.TargetType); } + if (!string.IsNullOrWhiteSpace(req.TargetName)) + { + query = query.Where(l => l.TargetName.Contains(req.TargetName)); + } + + if (!string.IsNullOrWhiteSpace(req.OptName)) + { + query = query.Where(l => l.OptName.Contains(req.OptName)); + } + if (req.StartDate.HasValue) { query = query.Where(l => l.OptDate >= req.StartDate.Value.Date); From 917eacc7b7b2cf980a7d331bd0c39b5fbf440635 Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 2 Jul 2026 16:46:28 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=88=B7=E6=96=B0=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96rule=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8EXception=E3=80=82=E9=9D=9E?= =?UTF-8?q?ACTIVE=E7=9A=84rule=E4=B8=8D=E5=86=8D=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 218 +++------- .../Modules/RiskEngine/RiskEngineService.cs | 387 ++++++++++++++++-- YLErpDAL/Modules/RiskEngine/RiskRule.cs | 3 +- .../Modules/RiskModule/QuotaMonitorService.cs | 32 +- YLErpWeb/Controllers/RiskRuleController.cs | 24 ++ YLErpWeb/Controllers/tradeController.cs | 2 +- 6 files changed, 455 insertions(+), 211 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 0d2f778f..aed0f953 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -41,7 +41,7 @@ namespace YLErp.Modules.RiskEngine /// 变量编码 → 类型全名的映射 /// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"]) /// - static IYcLogger _logger =LogFactory.GetLogger("RuleCompiler"); + static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); private static readonly Dictionary VariableTypeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["trade"] = "YLErp.DBModels.trade", @@ -50,43 +50,37 @@ namespace YLErp.Modules.RiskEngine ["credit"] = "YLErp.DBModels.credit", ["client_marginrate"] = "YLErp.DBModels.client_marginrate", ["market"] = "YLErp.DBModels.market", - ["calc"] = "YLErp.DBModels.calc", + ["underlying_manager"] = "YLErp.DBModels.underlying_manager", ["sys"] = "YLErp.DBModels.sys", ["eod_swap_position"] = "YLErp.DBModels.eod_swap_position", ["realtime_trade_risk"] = "YLErp.DBModels.realtime_trade_risk", }; - /// - /// 将规则编译为可执行委托,并写入 rule.CompiledScript - /// 调用时机:规则创建时 / 规则加载时 / 缓存刷新时 - /// - public static void Compile(this RiskRule rule) - { - var result = ValidateAndCompileRule(rule); - if (!result.Success) - { - throw new InvalidOperationException(result.ErrorMessage); - } - } - /// /// 校验并编译规则表达式。 /// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。 /// - public static RuleCompileResult ValidateAndCompileFormula(string formulaExp) + public static RuleCompileResult ValidateAndCompileFormula(int ruleId, string formulaExp) { if (string.IsNullOrWhiteSpace(formulaExp)) { + _logger.Error($"规则表达式为空,无法编译 - RuleId: {ruleId}"); return RuleCompileResult.Fail("规则 RuleExpr 不能为空"); } try { - var compiled = CompileScript(formulaExp); + var compiled = CompileScript(ruleId, formulaExp, out string compileErrorMessage); + if (compiled == null) + { + return RuleCompileResult.Fail(compileErrorMessage ?? "脚本编译失败"); + } + return RuleCompileResult.Ok(compiled); } catch (Exception ex) { + _logger.Error($"规则表达式校验异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{formulaExp}"); return RuleCompileResult.Fail(ex.Message); } } @@ -98,17 +92,27 @@ namespace YLErp.Modules.RiskEngine { if (rule == null) { + _logger.Error("规则对象为空,无法编译 - RuleId: 0"); return RuleCompileResult.Fail("规则不能为空"); } - + if (rule.Id <= 0) { + _logger.Error($"规则 Id 非法,无法编译 - RuleId: {rule.Id}"); return RuleCompileResult.Fail("规则 Id 不能为空"); } + if (rule.Status != RiskRuleStatus.Active) + { + _logger.Error($"规则状态非启用,跳过编译 - RuleId: {rule.Id}, Status: {rule.Status}"); + return RuleCompileResult.Fail("规则 状态为未启动"); + } - var result = ValidateAndCompileFormula(rule.RuleExpr); + var result = ValidateAndCompileFormula(rule.Id, rule.RuleExpr); if (!result.Success) { + _logger.Error($"规则校验失败 - RuleId: {rule.Id}, Error: {result.ErrorMessage}"); + rule.CompiledScript = null; + RuleCompiledCache.Remove(rule.Id.ToString()); return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}"); } @@ -117,111 +121,18 @@ namespace YLErp.Modules.RiskEngine return result; } - /// - /// 解析 ConditionJson,提取条件列表。 - /// 要求 expression 明确描述实际取值路径。 - /// 当前主执行链不再使用该方法,仅作为早期方案保留。 - /// - // 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 exprParts = new List(); - - foreach (var (_, expression, op, value, variableType) in conditions) - { - // 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量 - string leftExpr = BuildMemberAccessExpression(expression, variableType); - - // 格式化阈值 - 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。 - /// 数值类型会自动包一层 Convert.ToDecimal,便于与 decimal 阈值比较。 - /// - private static string BuildMemberAccessExpression(string expression, string variableType) - { - var parts = expression.Split('.'); - if (parts.Length < 2) - { - throw new ArgumentException($"expression 格式不正确:{expression}"); - } - - string prefix = parts[0].Trim().ToLower(); - if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName)) - { - throw new ArgumentException($"未知前缀:{prefix}"); - } - - // 除前缀外,其余部分都视为成员访问路径,便于后续扩展多级属性访问 - string memberAccess = string.Join(".", parts.Skip(1).Select(p => p.Trim())); - string objectExpr = $"(({typeFullName})DataMap[\"{prefix}\"]).{memberAccess}"; - - if (variableType == "numeric" || variableType == "number") - { - return $"Convert.ToDecimal({objectExpr})"; - } - - return objectExpr; - } - /// /// 用 Roslyn 编译 C# 脚本代码为可执行委托 /// - private static Func CompileScript(string scriptCode) + private static Func CompileScript(int ruleId, string scriptCode, out string errorMessage) { + errorMessage = null; + // 配置编译选项:引用必要的程序集 var options = ScriptOptions.Default .WithReferences( - typeof(RiskContext).Assembly, // YLErpDAL - typeof(YLErp.DBModels.trade).Assembly // Model 所在程序集 + typeof(RiskContext).Assembly, + typeof(YLErp.DBModels.trade).Assembly ) .WithImports("System"); @@ -235,9 +146,9 @@ namespace YLErp.Modules.RiskEngine var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); if (errors.Any()) { - string errorMsg = string.Join("; ", errors.Select(e => e.GetMessage())); - _logger.Error($"规则编译失败:{errorMsg}\n脚本代码:{scriptCode}"); - throw new InvalidOperationException($"脚本编译失败:{errorMsg}"); + errorMessage = string.Join("; ", errors.Select(e => e.GetMessage())); + _logger.Error($"规则编译失败 - RuleId: {ruleId}, Error: {errorMessage}\n脚本代码:{scriptCode}"); + return null; } // 生成可调用委托 @@ -246,71 +157,44 @@ namespace YLErp.Modules.RiskEngine // 包装为同步的 Func return ctx => { - var globals = new ScriptGlobals { DataMap = ctx.DataMap }; try { + var globals = new ScriptGlobals { DataMap = ctx.DataMap }; return runner(globals).GetAwaiter().GetResult(); } catch (Exception ex) { // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 - _logger.Error($"规则执行异常:{ex.Message}\n脚本代码:{scriptCode}"); + _logger.Error($"规则执行异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{scriptCode}"); return false; } }; } + } - /// - /// 格式化阈值为 C# 字面量 - /// - private static string FormatValueLiteral(object value, string variableType) + public class BuildMemberAccessResult + { + public bool Success { get; set; } + + public string Expression { get; set; } + + public string ErrorMessage { get; set; } + + public static BuildMemberAccessResult Ok(string expression) { - if (value == null) return "null"; - - string strValue = value.ToString(); - - // 数值类型统一加 m 后缀(decimal) - if (variableType == "numeric" || variableType == "number") + return new BuildMemberAccessResult { - if (decimal.TryParse(strValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d)) - { - return d.ToString(CultureInfo.InvariantCulture) + "m"; - } - } - - // 布尔 - if (variableType == "boolean" || variableType == "bool") - { - if (bool.TryParse(strValue, out bool b)) - { - return b ? "true" : "false"; - } - } - - // 日期 - if (variableType == "date" && DateTime.TryParse(strValue, out DateTime dt)) - { - return $"DateTime.Parse(\"{dt:yyyy-MM-dd}\")"; - } - - // 字符串(兜底) - return $"\"{strValue.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + Success = true, + Expression = expression + }; } - /// - /// 将操作符统一为 C# 操作符 - /// - private static string FormatOperator(string op) + public static BuildMemberAccessResult Fail(string errorMessage) { - return op?.Trim() switch + return new BuildMemberAccessResult { - "=" or "==" => "==", - "!=" or "≠" => "!=", - ">" => ">", - "<" => "<", - ">=" => ">=", - "<=" => "<=", - _ => throw new ArgumentException($"不支持的操作符:{op}") + Success = false, + ErrorMessage = errorMessage }; } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 845149d3..8f4c3470 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -157,20 +157,20 @@ namespace YLErp.Modules.RiskEngine var rules = LoadRulesFromDb(); var applications = LoadApplicationsFromDb(); - List res = new List(); - foreach(var i in applications) - { - if(i.Status!= RiskRuleStatus.Active) - { - res.Add(i.Id); - } - } // 预编译所有规则到 RuleCompiledCache foreach (var rule in rules) { var ruleId = rule.Id.ToString(); + //已存在编译缓存的规则直接跳过,避免重复编译 if (RuleCompiledCache.TryGet(ruleId, out _)) { + _logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}"); + continue; + } + //非活跃的rule不编译 + if (rule.Status != RiskRuleStatus.Active) + { + _logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}"); continue; } @@ -321,16 +321,23 @@ namespace YLErp.Modules.RiskEngine } var applicationRules = rules - .Where(r => applicationRuleIds.Contains(r.Id) && r.Id == 3) + .Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active) .ToList(); - var missingRuleIds = applicationRuleIds - .Where(ruleId => applicationRules.All(r => r.Id != ruleId)) - .ToList(); + // 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志 + var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id)) + .ToDictionary(r => r.Id); - foreach (var missingRuleId in missingRuleIds) + foreach (var ruleId in applicationRuleIds) { - _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {missingRuleId}, ApplicationRuleIds: {application.RuleIds}"); + if (!ruleDict.TryGetValue(ruleId, out var ruleDef)) + { + _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {ruleId}, ApplicationRuleIds: {application.RuleIds}"); + } + else if (ruleDef.Status != RiskRuleStatus.Active) + { + _logger.Info($"[风控引擎] 规则非活跃,跳过执行 - RuleId: {ruleId}, Status: {ruleDef.Status}, ApplicationRuleIds: {application.RuleIds}"); + } } foreach (var rule in applicationRules) @@ -379,7 +386,6 @@ namespace YLErp.Modules.RiskEngine switch (application.ControlStrategy) { case RiskControlStrategy.Block: - break; result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo @@ -465,36 +471,324 @@ namespace YLErp.Modules.RiskEngine /// private List LoadRulesFromDb() { - return DbContext.glms_risk_rule + var rules = DbContext.glms_risk_rule .AsNoTracking() - .Where(r => r.Status != RiskRuleStatus.Deleted) .OrderByDescending(r => r.UpdateDate ?? r.OptDate) .Select(r => new RiskRule { + Status = r.Status, 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(); + }).ToList() ; + return rules; + //#region 测试本地规则 + //rules.Add(new RiskRule + //{ + // Id = 1000001, + // RuleName = "挂钩标的集中度校验(本地)", + // RuleText = "取值字段:分子取 QuotaMonitorService 已注入 DataMap[same_underlying_total_notional],该值按存续交易口径汇总同一标的 StockEqvNotional;分母取 DataMap[underlying_manager].IssueSize,债券标的场景下该值由 underlying_manager.ExJson 反序列化回填,含义按当前业务测试口径使用发行量(亿)。为什么这么取:当前上下文已经稳定注入了这两个值,且与集中度规则最接近正式口径。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量需先乘 100000000 还原为元,结果大于 30% 时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"same_underlying_total_notional\") && DataMap[\"same_underlying_total_notional\"] != null && DataMap.ContainsKey(\"underlying_manager\") && DataMap[\"underlying_manager\"] != null && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.HasValue && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.Value > 0 && Convert.ToDecimal(DataMap[\"same_underlying_total_notional\"]) / (((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.Value * 100000000m) * 100m > 30m", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000004, + // RuleName = "保证金支付比例超阈值(本地)", + // RuleText = "取值字段:直接取 DataMap[trade].MarginRate,对应 trade 表保证金率字段。为什么这么取:该字段已经在当前上下文稳定注入,且 seed 规则 4 的判断核心也是保证金比例。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate > 0.5", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000005, + // RuleName = "保证金利率偏离(本地)", + // RuleText = "取值字段:正式口径应取收益互换扩展数据中的保证金利率字段,但当前 RiskContext 未注入 trade_swap,因此本地测试先取 DataMap[trade].MarginRate 近似代替。为什么这么取:当前上下文只有 trade 可直接取值,先保证规则链路可验证。计算逻辑:若近似保证金利率不在 2% 到 5% 区间内,即小于 0.02 或大于 0.05,则触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate < 0.02) || (((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate > 0.05))", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000006, + // RuleName = "保证金收取比例低于最低标准(本地)", + // RuleText = "取值字段:正式口径应取配置项 MinMarginRate 或客户/品种最低保证金率,当前上下文未注入配置对象,因此本地测试仍取 DataMap[trade].MarginRate 做比较。为什么这么取:trade.MarginRate 是当前唯一稳定可得且能反映保证金比例的字段。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate < 0.2", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000007, + // RuleName = "起息日早于当前日期(本地)", + // RuleText = "取值字段:取 DataMap[trade].StartDate,对应 trade 表开始日。为什么这么取:seed 规则 7 直接使用 StartDate 与当前日期比较,当前上下文也已注入 trade。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.Value.Date < DateTime.Today", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000008, + // RuleName = "支付日为银行间交易日(本地)", + // RuleText = "取值字段:取 DataMap[trade].SettlementDate,对应 trade 表结算日期。为什么这么取:当前代码中支付相关日期可直接从 trade 取得,且项目已有 QdpCalendarHelper.GetNonHolidayDefore 可用于交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.Value.Date", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000009, + // RuleName = "到期日为银行间交易日(本地)", + // RuleText = "取值字段:取 DataMap[trade].ExerciseDate,对应当前交易里更接近业务到期/行权日的字段。为什么这么取:TradeBase 中 MaturityDate 注释已提示容易与 ExerciseDate 混淆,当前测试按交易实际到期处理字段 ExerciseDate 落地,避免先取错口径。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000010, + // RuleName = "平仓日为银行间交易日(本地)", + // RuleText = "取值字段:取 DataMap[trade].UnWindDate,对应 trade 表平仓日。为什么这么取:当前上下文已注入该字段,且 seed 规则中的平仓日判断在本地最接近该口径。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.Value.Date", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000011, + // RuleName = "合约期限超阈值(本地)", + // RuleText = "取值字段:取 DataMap[trade].StartDate 和 DataMap[trade].ExerciseDate。为什么这么取:seed 规则 11 本质是计算合约剩余天数,当前 trade 中最稳定可得且最接近交易起止区间的就是开始日和到期/行权日。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && (((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date - ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.Value.Date).TotalDays > 365d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000012, + // RuleName = "债券类净价偏离(本地)", + // RuleText = "取值字段:当前上下文未注入债券估值净价 market.CBValuationPrice,因此本地测试先取 DataMap[trade].SpotPrice 作为可直接获得的价格字段。为什么这么取:债券类正式估值未接入前,需要用交易上已有价格先验证偏离类规则链路。计算逻辑:若 SpotPrice 有值且不为 0,则按 ABS(SpotPrice-100)/100×100% 计算相对面值 100 的偏离率,偏离率大于 5% 时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && Math.Abs(((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value - 100d) / 100d * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000013, + // RuleName = "债券类收益率偏离(本地)", + // RuleText = "取值字段:正式口径应比较 DataMap[trade].InitYtm 与市场估值收益率 market.CBValuationYtm,但当前未注入 market,因此本地测试直接取 trade.InitYtm。为什么这么取:InitYtm 是 trade 上已有且与收益率偏离最接近的字段。计算逻辑:先以 2.5% 作为本地测试基准收益率,若 InitYtm 有值且 ABS(InitYtm-0.025)/0.025×100% 大于 5%,则触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.Value - 0.025d) / 0.025d) * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000014, + // RuleName = "非债券类价格偏离(本地)", + // RuleText = "取值字段:正式口径应比较 trade.TradePrice 与市场参考价 market.ReferencePrice,当前未注入 market,因此本地测试直接取 trade.TradePrice 与 trade.SpotPrice 互相比对。为什么这么取:这两个字段都来自 trade,且能够表达成交价相对现价的偏离。计算逻辑:当 TradePrice 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(TradePrice/SpotPrice-1)×100% 计算偏离率,大于 5% 时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value) - 1d) * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000015, + // RuleName = "单一交易对手累计标的数量超阈值(本地)", + // RuleText = "取值字段:取 DataMap[trade].ClientId 作为交易对手标识,并在表达式里直接查询 trade 表的 UnderlyingId。为什么这么取:当前上下文尚未预先注入该聚合值,但 DbContext 在脚本环境可用,且项目已有存续口径可以复用。计算逻辑:按 ValidState 不等于 InValid、ParentTradeId 等于 0、TradeStatus 属于 NeedMarginTradeStatusList 或审批中 的存续口径,统计同一 ClientId 下去重后的 UnderlyingId 数量,超过 10 个时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.ClientId == ((YLErp.DBModels.trade)DataMap[\"trade\"]).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Select(t => t.UnderlyingId).Distinct().Count() > 10", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000016, + // RuleName = "多头支付固定端利率偏离(本地)", + // RuleText = "取值字段:正式口径应同时取 trade_swap 固定端方向、固定利率以及市场基准利率,当前未注入 trade_swap 和 market,因此本地测试先取 DataMap[trade].FixedRate,并结合 BuySell 判断多头方向。为什么这么取:FixedRate 和 BuySell 都已在 trade 上可取,能先验证方向类利率规则链路。计算逻辑:当 BuySell 表示多头且 FixedRate 有值时,先以 2.5% 作为本地测试基准,若 ABS(FixedRate-0.025)/0.025×100% 大于 5%,则触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Buy\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.Value - 0.025d) / 0.025d) * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000017, + // RuleName = "空头利率减点借贷加权偏离(本地)", + // RuleText = "取值字段:正式口径应取 trade_swap 空头端利率减点、借贷成本和加权基准,当前未注入这些对象,因此本地测试仍取 DataMap[trade].FixedRate,并结合 BuySell 判断空头方向。为什么这么取:当前 trade 上只有 FixedRate 可稳定表达利率数值,先用于验证空头分支规则。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Sell\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.Value - 0.02d) / 0.02d) * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000018, + // RuleName = "账户授权收支方向不匹配(本地)", + // RuleText = "取值字段:正式口径应取账户授权配置中的收支方向和当前交易实际收支方向,当前上下文未注入账户授权对象,因此本地测试先用 trade.OpponentRole 与 trade.BuySell 做占位判断。为什么这么取:当前只有 trade 上的方向类字段可直接取得,先用于验证禁止类规则是否能命中。计算逻辑:当 OpponentRole 和 BuySell 都有值,且出现本地定义的不允许组合时触发禁止;当前测试口径先将 OpponentRole 为 Pay 且 BuySell 为 Buy 视为方向不匹配。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole) && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell) && ((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole == \"Pay\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Buy\"", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000019, + // RuleName = "执行价偏离超阈值(本地)", + // RuleText = "取值字段:取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice,分别对应行权价与现价。为什么这么取:当前项目已有使用 Strike 与 SpotPrice 做偏离判断的场景,且这两个字段都已在 trade 上可直接获取。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value) - 1d) * 100d > 5d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000021, + // RuleName = "接近/触发敲入敲出价(本地)", + // RuleText = "取值字段:正式口径应取产品条款中的敲入价/敲出价以及现价,当前上下文未注入条款对象,因此本地测试先取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice 近似模拟触发价与现价。为什么这么取:这两个字段当前即可直接取得,适合先验证提示类规则链路。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。", + // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value) - 1d) * 100d <= 2d", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //return rules; + //#endregion } - /// - /// 从数据库加载应用配置 - /// + + private List LoadApplicationsFromDb() { - return DbContext.glms_risk_rule_application + var applications = DbContext.glms_risk_rule_application .AsNoTracking() - .Where(a => a.Status != RiskRuleStatus.Deleted) .OrderByDescending(a => a.UpdateDate ?? a.OptDate) .Select(a => new RiskRuleApplication { @@ -517,16 +811,33 @@ namespace YLErp.Modules.RiskEngine UpdateDate = a.UpdateDate ?? a.OptDate ?? DateTime.MinValue }) .ToList(); + return applications; } + // applications.Add(new RiskRuleApplication + // { + // Id = 1000001, + // RuleIds = "1000001", + // Status = RiskRuleStatus.Active, + // ControlStrategy = RiskControlStrategy.Approval, + // TriggerPoints = "BOOK_CONFIRM", + // ScopeIsGlobal = true, + // ScopeAssetBookIds = string.Empty, + // ScopeClientIds = string.Empty, + // ScopeUnderlyingTypes = string.Empty, + // ScopeTradeTypes = string.Empty, + // Version = 1, + // OptId = 0, + // OptName = "system", + //} - /// - /// 判断应用配置是否命中当前交易。 - /// 匹配规则遵循设计文档: - /// 1. 全局命中时直接返回 true; - /// 2. 同一维度内多选按并集处理; - /// 3. 不同维度之间按交集处理; - /// 4. 某维度留空表示该维度不限制。 - /// + /// + /// 判断应用配置是否命中当前交易。 + /// 匹配规则遵循设计文档: + /// 1. 全局命中时直接返回 true; + /// 2. 同一维度内多选按并集处理; + /// 3. 不同维度之间按交集处理; + /// 4. 某维度留空表示该维度不限制。 + /// private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade) { if (application == null) @@ -559,17 +870,17 @@ namespace YLErp.Modules.RiskEngine /// 解析应用配置中的规则ID列表。 /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 /// - private List ParseRuleIds(string ruleIds) + private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) { - return new List(); + return new List(); } - var ids = new List(); + var ids = new List(); foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { - if (long.TryParse(part.Trim(), out long id)) + if (int.TryParse(part.Trim(), out int id)) { ids.Add(id); } @@ -634,5 +945,5 @@ namespace YLErp.Modules.RiskEngine var property = trade.GetType().GetProperty("UnderlyingInstrumentType"); return property?.GetValue(trade); } - } +} } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs index 5558c2b4..8e8ef067 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -13,6 +13,7 @@ namespace YLErp.Modules.RiskEngine public int Id { get; set; } public string RuleName { get; set; } public string RuleText { get; set; } + /// /// 【核心】条件JSON(结构化模式的条件列表存储),如 @@ -29,7 +30,7 @@ namespace YLErp.Modules.RiskEngine // === 生命周期 === public int Version { get; set; } - public bool IsDeleted { get; set; } + public RiskRuleStatus Status { get; set; } // === 审计字段 === public int OptId { get; set; } diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index b5b7dd9a..2d7a1215 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4380,9 +4380,11 @@ namespace YLErp.Modules.RiskModule if (!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)) detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); res.ErrorMsg = string.Join("\n", detailParts); + //确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。 var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); //需清除ErrorMsg,不然外部调用会认为失败 + //触发需审批,交易特批逻辑 if (ignoreRiskWarning && isRiskApprovalWarning) { res.ErrorMsg = string.Empty; @@ -4755,14 +4757,14 @@ namespace YLErp.Modules.RiskModule _logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}"); var result = new QuotaTrial(); trade tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); - + // 检查交易对象是否存在 if (tradeObj == null) { _logger.Info($"[限额试算] 找不到交易记录 - tradeId: {tradeId}"); throw new ArgumentNullException(nameof(tradeObj), $"找不到ID为 {tradeId} 的交易记录"); } - + _logger.Info($"[限额试算] 交易信息 - tradeId: {tradeObj.id}, TradeNumber: {tradeObj.TradeNumber}, TradeType: {tradeObj.TradeType}, ClientId: {tradeObj.ClientId}"); //// 检查关键字段是否为null @@ -4968,8 +4970,30 @@ namespace YLErp.Modules.RiskModule TradeId = tradeId, TriggerPoint = "BOOK_CONFIRM" }; - // 构造 DataMap(第一版只塞 trade 对象,后续按需扩展) + tradeObj.StockEqvNotional = 10000000000; riskContext.DataMap["trade"] = tradeObj; + var sameUnderlyingTotalNotional = DbContext.trade + .Where(t => t.ValidState != "InValid" + && t.UnderlyingId == tradeObj.UnderlyingId + && t.ParentTradeId == 0 + && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == "审批中")) + .Sum(t => (double?)t.StockEqvNotional) ?? 0d; + riskContext.DataMap["same_underlying_total_notional"] = sameUnderlyingTotalNotional; + var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(tradeObj.UnderlyingCode); + if (underlying != null) + { + if (underlying.IsBond() && !string.IsNullOrEmpty(underlying.ExJson)) + { + var bond = JsonHelper.Deserialize(underlying.ExJson); + if (bond != null) + { + underlying.IssueSize = bond.IssueSize; + underlying.UnderlyingFullName = bond.UnderlyingFullName; + underlying.UnderlyingIssuer = bond.UnderlyingIssuer; + } + } + riskContext.DataMap["underlying_manager"] = underlying; + } var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM"); _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}"); @@ -5016,7 +5040,7 @@ namespace YLErp.Modules.RiskModule } // ===== 新风控引擎接入结束 ===== - _logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); + //_logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); SaveQuotaTrial(result); return result; } diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index 55ba98e9..99158235 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -568,6 +568,30 @@ namespace YLErp.Web.Controllers #endregion + #region Cache Management + + [HttpGet("riskCache/refresh")] + [MyAuthorize("风险控制-异常交易监控")] + public JsonResult RefreshRiskEngineCache() + { + try + { + RiskEngineService.GetInstance().RefreshCache(); + return Json(new { success = true, message = "风控缓存刷新成功" }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "刷新风控缓存"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + #region Trade Types [HttpGet("trade-types")] diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 9959628b..06e7dae4 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2480,7 +2480,7 @@ namespace YLErp.Web.Controllers { return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } - //限额试算不通过(老风控检查失败),展示详情但不允许审批 + //限额试算不通过(老风控,新风控检查失败),展示详情但不允许审批 if (result.type == TradeOpenRetCode.QuotaTrialError.ToString()) { return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); From ecb71b35f622ef98d54341eb385bae0b3dcd0c1a Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 2 Jul 2026 17:04:20 +0800 Subject: [PATCH 03/20] =?UTF-8?q?fix:=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs | 4 ++-- YLErpDAL/Modules/RiskEngine/RiskEngineService.cs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index cd3db839..3ed2b753 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -60,7 +60,7 @@ namespace YLErp.Modules.RiskEngine /// 校验并编译规则表达式。 /// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。 /// - public static RuleCompileResult ValidateAndCompileFormula(int ruleId, string formulaExp) + public static RuleCompileResult ValidateAndCompileFormula(long ruleId, string formulaExp) { if (string.IsNullOrWhiteSpace(formulaExp)) { @@ -124,7 +124,7 @@ namespace YLErp.Modules.RiskEngine /// /// 用 Roslyn 编译 C# 脚本代码为可执行委托 /// - private static Func CompileScript(int ruleId, string scriptCode, out string errorMessage) + private static Func CompileScript(long ruleId, string scriptCode, out string errorMessage) { errorMessage = null; diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 63aacc30..30296d9d 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -870,17 +870,17 @@ namespace YLErp.Modules.RiskEngine /// 解析应用配置中的规则ID列表。 /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 /// - private List ParseRuleIds(string ruleIds) + private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) { - return new List(); + return new List(); } - var ids = new List(); + var ids = new List(); foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { - if (int.TryParse(part.Trim(), out int id)) + if (long.TryParse(part.Trim(), out long id)) { ids.Add(id); } From c8293db2277909b4fb5976f4dd61ee88a8a8bc53 Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 2 Jul 2026 17:19:39 +0800 Subject: [PATCH 04/20] =?UTF-8?q?fix=EF=BC=9A=E5=9B=9E=E9=80=80=E6=9C=9F?= =?UTF-8?q?=E6=9D=83=E4=BA=A4=E6=98=93=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wwwroot/Scripts/app/trade/tradeview.js | 74 ++++++------------- 1 file changed, 22 insertions(+), 52 deletions(-) diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js index c1943d3b..67ad44db 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js @@ -85,34 +85,6 @@ 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: "提示", @@ -122,23 +94,31 @@ var confirmFunc = function (id, additionalProcessing) { content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, yes: function (index) { var obj = window["layui-layer-iframe" + index].page; - if (!saveQuotaTrial(obj)) { - return; - } - if (isRiskWarningConfirm) { - if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { + 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"); 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(); } @@ -148,10 +128,8 @@ var confirmFunc = function (id, additionalProcessing) { if (data.obj.type === "LackOfMoney") { layerSetting.btn = [page.buttonStr, '取消']; } - else if (isRiskWarningConfirm) { - layerSetting.btn = ["交易特批", '取消']; - } - else if (data.obj.typecode === "FundStatus") { + // 只展示“资金状况”的情况 + if (data.obj.typecode === "FundStatus") { layerSetting.btn = ["确认", '取消']; } @@ -189,14 +167,6 @@ var confirmFunc = function (id, additionalProcessing) { }); }); } - if (data.msg && data.msg !== "操作完成") { - main.alert(data.msg, function () { - if (blClose) { - closetradeWindow(); - } - }); - return; - } if (blClose) { closetradeWindow(); } From dc0cb9ec7b466d402fe3916f024740fb659eb5b8 Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 6 Jul 2026 17:41:13 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat:=E8=80=81=E9=A3=8E=E6=8E=A7=E7=89=B9?= =?UTF-8?q?=E6=89=B9=E4=BD=BF=E7=94=A8=E5=8D=95=E7=8B=ACbool=E9=87=8F?= =?UTF-8?q?=E8=AF=86=E5=88=AB=EF=BC=8C=E6=96=B0=E9=A3=8E=E6=8E=A7=E9=9C=80?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=87=87=E7=94=A8=E6=96=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?RiskWarning=EF=BC=8C=E9=A3=8E=E6=8E=A7=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BC=98=E5=85=88=E7=BA=A7Error>Warning?= =?UTF-8?q?=EF=BC=88=E8=80=81=E9=A3=8E=E6=8E=A7=E7=89=B9=E6=89=B9=EF=BC=8C?= =?UTF-8?q?=E7=BB=99=E5=89=8D=E7=AB=AFCODE=E4=B8=BAERROR)>RiskWarning>Succ?= =?UTF-8?q?ess(=E5=8F=AF=E8=83=BD=E5=B8=A6=E6=9C=89ShowTIip)=E3=80=82?= =?UTF-8?q?=E6=96=B0=E9=A3=8E=E6=8E=A7=E7=89=B9=E6=89=B9=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E8=AF=86=E5=88=ABRULEID=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=87=87?= =?UTF-8?q?=E7=94=A8BOOL=E7=BB=9F=E4=B8=80=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Framework/YLErp.Core/DBModels/QuotaTrial.cs | 6 ++- .../YLErp.Core/Enums/QuotaMonitorEnums.cs | 7 ++- YLErpDAL/Helpers/StringHelper.cs | 21 +++++++- .../Modules/RiskEngine/RiskEngineService.cs | 12 ++--- .../Modules/RiskModule/QuotaMonitorService.cs | 49 ++++++++++++++----- .../RiskModule/TradeRiskCheckLogService.cs | 3 ++ .../DealModule/TradeConfirmService.cs | 13 +++-- .../DealModule/TradeOpenService.cs | 2 + YLErpWeb/Controllers/tradeController.cs | 17 ++++--- YLErpWeb/Views/trade/quotaTrial.cshtml | 3 ++ .../Scripts/app/swaptrade/swapTradeView.js | 20 ++++++-- 11 files changed, 119 insertions(+), 34 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/QuotaTrial.cs b/Framework/YLErp.Core/DBModels/QuotaTrial.cs index 233f0d3a..c4e95df1 100644 --- a/Framework/YLErp.Core/DBModels/QuotaTrial.cs +++ b/Framework/YLErp.Core/DBModels/QuotaTrial.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations.Schema; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using YLErp.Enums; namespace YLErp.DBModels @@ -62,5 +63,8 @@ namespace YLErp.DBModels /// 1:确认成交时系统调用; /// public int TrialSource { get; set; } + + [NotMapped] + public List ApprovalRuleIds { get; set; } = new List(); } } diff --git a/Framework/YLErp.Core/Enums/QuotaMonitorEnums.cs b/Framework/YLErp.Core/Enums/QuotaMonitorEnums.cs index 18486318..1aba4a14 100644 --- a/Framework/YLErp.Core/Enums/QuotaMonitorEnums.cs +++ b/Framework/YLErp.Core/Enums/QuotaMonitorEnums.cs @@ -1,4 +1,4 @@ -using YLErp.Models; +using YLErp.Models; namespace YLErp.Enums { @@ -143,6 +143,11 @@ namespace YLErp.Enums [Description("预警")] Warning, /// + /// 风控需审批 + /// + [Description("风控需审批")] + RiskWarning, + /// /// 不通过 /// [Description("不通过")] diff --git a/YLErpDAL/Helpers/StringHelper.cs b/YLErpDAL/Helpers/StringHelper.cs index 411c0c11..62140735 100644 --- a/YLErpDAL/Helpers/StringHelper.cs +++ b/YLErpDAL/Helpers/StringHelper.cs @@ -1,4 +1,4 @@ -namespace YLErp.Helpers +namespace YLErp.Helpers { public static class StringHelper { @@ -49,5 +49,24 @@ var decExp = Math.Pow(10, decimalSize + 1); return (Math.Floor(value.Value * decExp) / decExp).ToString("f" + decimalSize); } + + /// + /// 将逗号分隔的字符串转换为字符串数组,并去除空白项与重复项。 + /// + /// 逗号分隔的字符串。 + /// 去除空白项与重复项后的字符串数组。 + public static string[] ConvertCommaValuesToStringArray(string commaValues) + { + if (string.IsNullOrWhiteSpace(commaValues)) + { + return new string[0]; + } + + return commaValues.Split(',') + .Select(o => o?.Trim()) + .Where(o => !string.IsNullOrWhiteSpace(o)) + .Distinct() + .ToArray(); + } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 30296d9d..fea278d0 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -161,18 +161,18 @@ namespace YLErp.Modules.RiskEngine foreach (var rule in rules) { var ruleId = rule.Id.ToString(); - //已存在编译缓存的规则直接跳过,避免重复编译 - if (RuleCompiledCache.TryGet(ruleId, out _)) - { - _logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}"); - continue; - } //非活跃的rule不编译 if (rule.Status != RiskRuleStatus.Active) { _logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}"); continue; } + //已存在编译缓存的规则直接跳过,避免重复编译 + if (RuleCompiledCache.TryGet(ruleId, out _)) + { + _logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}"); + continue; + } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (!compileResult.Success) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 2d7a1215..00f70ae6 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4335,7 +4335,7 @@ namespace YLErp.Modules.RiskModule return QuotaCheck(ref res, false); } - public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning) + public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) { if (res == null || res.Trade == null) { @@ -4381,14 +4381,27 @@ namespace YLErp.Modules.RiskModule detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); res.ErrorMsg = string.Join("\n", detailParts); //确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。 - var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning - && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); - //需清除ErrorMsg,不然外部调用会认为失败 - //触发需审批,交易特批逻辑 - if (ignoreRiskWarning && isRiskApprovalWarning) + var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.RiskWarning + && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails) + && (quotaObj.ApprovalRuleIds?.Any() ?? false); + //当前为老风控特批,不是新风控需审批 + res.OldRiskNeedSpecialApproval = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning; + if (isRiskApprovalWarning) { - res.ErrorMsg = string.Empty; - return true; + var currentApprovalRuleIds = quotaObj.ApprovalRuleIds ?? new List(); + res.IgnoredRiskRuleIds = currentApprovalRuleIds; + //需清除ErrorMsg,不然外部调用会认为失败 + //触发需审批,交易特批逻辑 + if (ignoreRiskWarning) + { + var ignoredRuleIdSet = new HashSet((ignoreRiskRuleIds ?? Enumerable.Empty()).Where(o => !string.IsNullOrWhiteSpace(o))); + var newApprovalRuleIds = currentApprovalRuleIds.Where(o => !ignoredRuleIdSet.Contains(o)).ToList(); + if (!newApprovalRuleIds.Any()) + { + res.ErrorMsg = string.Empty; + return true; + } + } } res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; return false; @@ -4959,7 +4972,6 @@ namespace YLErp.Modules.RiskModule result.RiskWarningDetails = riskWarningMsg ?? ""; result.QuotaWarningDetails = quotaWarningMsg ?? ""; result.AvailableForClient = availableMsg ?? ""; - result.TrialSource = trialSource; // ===== 新风控引擎接入点(设计文档 4.6.1) ===== try @@ -5010,12 +5022,25 @@ namespace YLErp.Modules.RiskModule } if (riskResult.NeedApproval) { - if (result.TrialStatus != QuotaTrialStatusEnum.Error) + if (result.TrialStatus != QuotaTrialStatusEnum.Error && result.TrialStatus != QuotaTrialStatusEnum.Warning) { - result.TrialStatus = QuotaTrialStatusEnum.Warning; + result.TrialStatus = QuotaTrialStatusEnum.RiskWarning; } result.RiskWarningDetails += "[风控引擎] 规则触发:需审批\n"; - foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval)) + var approvalTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval).ToList(); + var approvalTriggeredRuleIds = approvalTriggeredRules.Select(r => r.RuleId) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + var showTipTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip).ToList(); + var showTipTriggeredRuleIds = showTipTriggeredRules.Select(r => r.RuleId) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + // 二次确认时,需审批规则允许按首次命中结果忽略;同时已展示过的提示规则也不再重复展示 + var processedRiskRuleIds = approvalTriggeredRuleIds.Concat(showTipTriggeredRuleIds) + .Distinct() + .ToList(); + result.ApprovalRuleIds = processedRiskRuleIds; + foreach (var triggeredRule in approvalTriggeredRules) { result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; } diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 53ac78c3..a5d508d8 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -97,6 +97,9 @@ namespace YLErp.Modules.RiskModule case QuotaTrialStatusEnum.Warning: exportModel.trial_result = "预警"; break; + case QuotaTrialStatusEnum.RiskWarning: + exportModel.trial_result = "风控需审批"; + break; case QuotaTrialStatusEnum.Error: exportModel.trial_result = "限额"; break; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index bedb5873..7bca1074 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -46,7 +46,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// 2021-02-04改造:批量确认因为耗时太长,用户多页面(交易列表,今日交易)并发确认导致生成多条期权费资金记录 /// - public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false) + public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null) { if (tradeids is null || !tradeids.Any(n => n > 0)) { @@ -101,7 +101,8 @@ namespace YLErp.Modules.TradeModule.DealModule confirmedTradeIds: result.confirmedTradeIds, changeConfirmPaths: result.changeConfirmPaths, continueQuotaCheck: idCache.Contains(pid), - ignoreRiskWarning: ignoreRiskWarning); + ignoreRiskWarning: ignoreRiskWarning, + ignoreRiskRuleIds: ignoreRiskRuleIds); if (pid > 0) { idCache.Add(pid); @@ -120,8 +121,10 @@ namespace YLErp.Modules.TradeModule.DealModule if (temp.RetCode == TradeOpenRetCode.QuotaTrialError || temp.RetCode == TradeOpenRetCode.FundStatus || temp.RetCode == TradeOpenRetCode.RiskWarning) { result.LackOfMoney = true; + result.OldRiskNeedSpecialApproval = temp.OldRiskNeedSpecialApproval; result.TrialDataId = temp.TrialDataId; result.TradeId = temp.Trade.id; + result.ignoreRiskRuleIds = temp.IgnoredRiskRuleIds; sbError.AppendLine(temp.ErrorMsg); break; } @@ -162,7 +165,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 ignoreRiskWarning = false) + bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false, IEnumerable ignoreRiskRuleIds = null) { var td = DbContext.trade.FirstOrDefault(t => t.id == tid); var result = new TradeOpenResult(td ?? new trade()); @@ -217,7 +220,7 @@ namespace YLErp.Modules.TradeModule.DealModule } var trialService = new QuotaMonitorService(this); - if (!trialService.QuotaCheck(ref result, ignoreRiskWarning)) + if (!trialService.QuotaCheck(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) { return result; } @@ -618,6 +621,7 @@ namespace YLErp.Modules.TradeModule.DealModule public class TradeConfirmResultModel { public bool LackOfMoney; + public bool OldRiskNeedSpecialApproval; public int TrialDataId; public int TradeId; public List confirmedTradeIds; @@ -625,5 +629,6 @@ namespace YLErp.Modules.TradeModule.DealModule public string errorMsg; public string tipMsg; public string type; + public List ignoreRiskRuleIds; } } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 448967ec..0915baff 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -787,6 +787,8 @@ namespace YLErp.Modules.TradeModule.DealModule public TradeOpenRetCode RetCode; public int TrialDataId; + public List IgnoredRiskRuleIds; + public bool OldRiskNeedSpecialApproval; /// /// 错误信息 diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 06e7dae4..98a3d539 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2447,7 +2447,7 @@ namespace YLErp.Web.Controllers } - public JsonResult tradeConfirm(string tradeids, string additionalProcessing, bool isSkipCheck = false) + public JsonResult tradeConfirm(string tradeids, string additionalProcessing, string ignoreRiskRuleIds, bool isSkipCheck = false) { var tradeidArr = DataConvert.ConvertCommaValuesToInt32Array(tradeids); if (tradeids is null || !tradeids.Any(n => n > 0)) @@ -2457,32 +2457,37 @@ 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 ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); var isSkipApproval = false; if (CurUser.交易管理_特批批量确认 && isSkipCheck) { ignoreMoneyCheck = true; isSkipApproval = true; } - var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning); + var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr); //如果客户缺少资金而操作者有交易特批权限 if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney) { if (result.type == TradeOpenRetCode.RiskWarning.ToString()) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "RiskWarning", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type, ignoreRiskRuleIds = result.ignoreRiskRuleIds }); } if (result.type == TradeOpenRetCode.FundStatus.ToString()) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "FundStatus", 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 }); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "LackOfMoney", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } - //限额试算不通过(老风控,新风控检查失败),展示详情但不允许审批 + //限额试算不通过时,延用master老逻辑:老风控走AdditionalProcessing并允许前端展示“交易特批”;新风控检查失败仍只展示详情不允许审批 if (result.type == TradeOpenRetCode.QuotaTrialError.ToString()) { + if (result.OldRiskNeedSpecialApproval) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "OldRiskQuotaTrialError", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } } diff --git a/YLErpWeb/Views/trade/quotaTrial.cshtml b/YLErpWeb/Views/trade/quotaTrial.cshtml index 7751409d..b04ecd25 100644 --- a/YLErpWeb/Views/trade/quotaTrial.cshtml +++ b/YLErpWeb/Views/trade/quotaTrial.cshtml @@ -101,6 +101,9 @@ case YLErp.Enums.QuotaTrialStatusEnum.Warning: 预警 break; + case YLErp.Enums.QuotaTrialStatusEnum.RiskWarning: + 风控需审批 + break; case YLErp.Enums.QuotaTrialStatusEnum.Error: 不通过 break; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index ad78eeb4..2f6eb85f 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -49,11 +49,14 @@ function showSwapRate(timeRate, isMarginLeg) { $("#swapIntervalModal").modal("show"); } var layerIndex = -1; -var confirmFunc = function (id, additionalProcessing) { +var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds) { var pData = { tradeids: id }; if (!main.isEmpty(additionalProcessing)) { pData.additionalProcessing = additionalProcessing; } + if (ignoreRiskRuleIds && ignoreRiskRuleIds.length > 0) { + pData.ignoreRiskRuleIds = ignoreRiskRuleIds.join(','); + } main.post("/trade/tradeConfirm", pData).done(function (data) { if (!data.success) { main.message(data.msg); @@ -90,8 +93,16 @@ var confirmFunc = function (id, additionalProcessing) { } if (data.obj && data.obj.proccessType === "AdditionalProcessing") { if (data.obj.TrialDataId) { - var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm"; + // 交易特批来源,优先使用后端新增的 source 字段区分老风控/新风控,同时兼容旧返回结构。 + var additionalProcessingSource = data.obj.source || ""; + // 新风控需审批场景:source=RiskWarning 或 type=RiskWarningConfirm,二次确认时需要继续透传 ignoreRiskRuleIds。 + var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm" || additionalProcessingSource === "RiskWarning"; + // 老风控兼容特批场景:source=OldRiskQuotaTrialError 或 type=LackOfMoney,保持 master 原有“交易特批”按钮逻辑。 + var isOldRiskSpecialApproval = additionalProcessingSource === "OldRiskQuotaTrialError" || data.obj.type === "LackOfMoney"; + // 新风控特批走 RiskWarningConfirm,老风控特批继续走 LackOfMoney,兼容当前 confirmFunc 的入参约定。 var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + // 新风控需审批时由后端返回本次命中的审批规则 id,前端二次确认时原样透传。 + var currentIgnoreRiskRuleIds = data.obj.ignoreRiskRuleIds || []; var saveQuotaTrial = function (obj) { var saveSuccess = true; if (obj.Data.Remark && obj.Data.Remark.length > 0) { @@ -135,8 +146,11 @@ var confirmFunc = function (id, additionalProcessing) { return; } } + if (isOldRiskSpecialApproval) { + obj.Data.Remark = (obj.Data.Remark || "").trim(); + } layer.close(layerIndex); - confirmFunc(id, additionalProcessingType); + confirmFunc(id, additionalProcessingType, currentIgnoreRiskRuleIds); }, cancel: function () { var iframeWindow = window["layui-layer-iframe" + this.index]; From 1c0fd97efacc7a4555ddf99ce4fb5abab20d1a95 Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 6 Jul 2026 18:00:06 +0800 Subject: [PATCH 06/20] =?UTF-8?q?fix:=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 00f70ae6..189382a8 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4982,7 +4982,6 @@ namespace YLErp.Modules.RiskModule TradeId = tradeId, TriggerPoint = "BOOK_CONFIRM" }; - tradeObj.StockEqvNotional = 10000000000; riskContext.DataMap["trade"] = tradeObj; var sameUnderlyingTotalNotional = DbContext.trade .Where(t => t.ValidState != "InValid" From de628719dc70e57f9e43e651e2c8ef742224ec89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Tue, 7 Jul 2026 13:51:24 +0800 Subject: [PATCH 07/20] resolve confilct QuotaMonitorService.cs --- .../Modules/RiskModule/QuotaMonitorService.cs | 329 +++++++++++++++--- 1 file changed, 275 insertions(+), 54 deletions(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 189382a8..677dde69 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -795,6 +795,19 @@ namespace YLErp.Modules.RiskModule join p in DbContext.swap_position on t.id equals p.SwapTradeId where !p.IsInitial && p.PosiDirection > 0 && !p.Invalid select p; + // 计算互换DV汇总(按标的缓存bondPrice) + var swapPositions = posiQuery.ToList(); + var swapDV = 0d; + var swapBPCache = swapPositions.Select(p => p.UnderlyingCode).Distinct() + .ToDictionary(code => code, code => EodPriceQueryService.GetBondPrice(valuedateBLL.ValueDate, code)); + foreach (var pos in swapPositions) + { + if (!swapBPCache.TryGetValue(pos.UnderlyingCode, out var bondPrice) || bondPrice == null) continue; + var vobp = bondPrice.Vobp ?? 0; + if (vobp == 0) continue; + swapDV += Convert.ToDouble(Dv01Helper.CalcDv01(pos.UnderlyingCode, pos.PosiQuantity, pos.PosiDirection, pos.PositionType, vobp)); + } + var setValue = new Action>((obj, settings) => { obj.StockEqvNotional = Convert.ToDouble(posiQuery.Sum(s => s.PosiNotionalValue)); @@ -907,6 +920,10 @@ namespace YLErp.Modules.RiskModule var allSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.GLOBAL_ALL); allSetting = MargeQuotaSetting(allSetting, 0, 0); + var globalDvSetting = allSetting.Where(O => O.IsValid && O.Status == QuotaSettingApprovalStatus.Valid && O.QuotaRange == 0 && O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); + var tradeSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.TRADE); + tradeSetting = MargeQuotaSetting(tradeSetting, 0, 0); + var tradeDvSetting = tradeSetting.Where(O => O.IsValid && O.Status == QuotaSettingApprovalStatus.Valid && O.QuotaRange == 0 && O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); var swapSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.GLOBAL_SWAP); swapSetting = MargeQuotaSetting(swapSetting, 0, 0); var swap = new QuotaMonitor_Global() @@ -915,6 +932,11 @@ namespace YLErp.Modules.RiskModule BusinessType = "互换" }; setValue(swap, swapSetting); + swap.DV = swapDV; + //swap.Quota_DV_Upper = tradeDvSetting?.QuotaUpperLimit ?? double.NaN; + //swap.Quota_DV_Lower = tradeDvSetting?.QuotaLowerLimit ?? double.NaN; + //swap.Quota_DV_wUpper = tradeDvSetting?.WarningUpperLimit ?? double.NaN; + //swap.Quota_DV_wLower = tradeDvSetting?.WarningLowerLimit ?? double.NaN; var positionList = new List>(); var underly = GetTradePositionPnl(); @@ -926,6 +948,21 @@ namespace YLErp.Modules.RiskModule positionList.AddRange(swapPositionList); } var checkPosiList = GetCheckPosiList(); + // 计算checkPosiList中所有持仓的DV合计,用于算未簿记DV(按标的缓存bondPrice) + var totalCheckPosiDV = 0d; + if (checkPosiList.Any()) + { + var ckBPCache = checkPosiList.Select(p => p.UnderlyingCode).Distinct() + .ToDictionary(code => code, code => EodPriceQueryService.GetBondPrice(valuedateBLL.ValueDate, code)); + foreach (var pos in checkPosiList) + { + if (!ckBPCache.TryGetValue(pos.UnderlyingCode, out var bp) || bp == null) continue; + var vobp = bp.Vobp ?? 0; + if (vobp == 0) continue; + int positionType = pos.Side == 0 ? (int)PositionTypeFlag.Long : (int)PositionTypeFlag.Short; + totalCheckPosiDV += Convert.ToDouble(Dv01Helper.CalcDv01(pos.UnderlyingCode, pos.Qty, pos.Direction, positionType, vobp)); + } + } var posiStockEqvNotional = checkPosiList.Sum(s => s.Pv); var delta = checkPosiList.Sum(s => s.Delta); var unTrade = new QuotaMonitor_Global() @@ -933,14 +970,20 @@ namespace YLErp.Modules.RiskModule ParentKey = "场外", BusinessType = "未簿记合约", StockEqvNotional = Convert.ToDouble(posiStockEqvNotional) - swap.StockEqvNotional.Normalize(), - DeltaExposure=(double)delta- (swap.DeltaExposure??0) + DeltaExposure=(double)delta- (swap.DeltaExposure??0), + DV = totalCheckPosiDV - (swap.DV ?? 0), }; var all = new QuotaMonitor_Global() { BusinessType = "全局", StockEqvNotional = Convert.ToDouble(posiStockEqvNotional), PositionPnl = underly.PositionPnl + swap.PositionPnl, - DeltaExposure=(double)delta+ underly.DeltaExposure + DeltaExposure=(double)delta+ underly.DeltaExposure, + DV = (swap.DV ?? 0) + (underly.DV ?? 0) + (unTrade.DV ?? 0), + Quota_DV_Upper = globalDvSetting?.QuotaUpperLimit ?? double.NaN, + Quota_DV_Lower = globalDvSetting?.QuotaLowerLimit ?? double.NaN, + Quota_DV_wUpper = globalDvSetting?.WarningUpperLimit ?? double.NaN, + Quota_DV_wLower = globalDvSetting?.WarningLowerLimit ?? double.NaN, }; var list = new List { @@ -963,9 +1006,13 @@ namespace YLErp.Modules.RiskModule /// public List QueryEodGlobalFromCalc(QuotaMonitorReq req) { - var posiQuery = DbContext.eod_swap_position.Where(O =>O.ValueDate==req.ValueDate&&O.PosiQuantity>0); + var maxDate = DbContext.eod_swap_position.Where(O => O.PosiQuantity > 0).Max(O => (DateTime?)O.ValueDate) ?? req.ValueDate; + var posiQuery = DbContext.eod_swap_position.Where(O =>O.ValueDate==maxDate&&O.PosiQuantity>0); req.PvPercent = 1 - req.PvPercent; + + // 互换DV汇总:直接取eod_swap_position中已计算好的dv01 + var eodSwapDV = Convert.ToDouble(posiQuery.Sum(p => p.dv01)); var setValue = new Action>((obj, settings) => { @@ -988,6 +1035,10 @@ namespace YLErp.Modules.RiskModule var allSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.GLOBAL_ALL); allSetting = MargeQuotaSetting(allSetting, 0, 0); + var globalDvSetting = allSetting.Where(O => O.IsValid && O.Status == QuotaSettingApprovalStatus.Valid && O.QuotaRange == 0 && O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); + var tradeSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.TRADE); + tradeSetting = MargeQuotaSetting(tradeSetting, 0, 0); + var tradeDvSetting = tradeSetting.Where(O => O.IsValid && O.Status == QuotaSettingApprovalStatus.Valid && O.QuotaRange == 0 && O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); var swapSetting = QueryCurrentQuotaSetting(QuotaTypeEnum.GLOBAL_SWAP); swapSetting = MargeQuotaSetting(swapSetting, 0, 0); var swap = new QuotaMonitor_Global() @@ -996,6 +1047,11 @@ namespace YLErp.Modules.RiskModule BusinessType = "互换", }; setValue(swap, swapSetting); + swap.DV = eodSwapDV; + swap.Quota_DV_Upper = tradeDvSetting?.QuotaUpperLimit ?? double.NaN; + swap.Quota_DV_Lower = tradeDvSetting?.QuotaLowerLimit ?? double.NaN; + swap.Quota_DV_wUpper = tradeDvSetting?.WarningUpperLimit ?? double.NaN; + swap.Quota_DV_wLower = tradeDvSetting?.WarningLowerLimit ?? double.NaN; var underly = GetEodTradePositionPnl(req.ValueDate); var unTrade = new QuotaMonitor_Global() { @@ -1009,7 +1065,12 @@ namespace YLErp.Modules.RiskModule BusinessType = "全局", StockEqvNotional = swap.StockEqvNotional, PositionPnl = underly.PositionPnl + swap.PositionPnl, - DeltaExposure = swap.DeltaExposure+ underly.DeltaExposure + DeltaExposure = swap.DeltaExposure+ underly.DeltaExposure, + DV = (swap.DV ?? 0) + (underly.DV ?? 0) + (unTrade.DV ?? 0), + Quota_DV_Upper = globalDvSetting?.QuotaUpperLimit ?? double.NaN, + Quota_DV_Lower = globalDvSetting?.QuotaLowerLimit ?? double.NaN, + Quota_DV_wUpper = globalDvSetting?.WarningUpperLimit ?? double.NaN, + Quota_DV_wLower = globalDvSetting?.WarningLowerLimit ?? double.NaN, }; var list = new List { @@ -1323,11 +1384,23 @@ namespace YLErp.Modules.RiskModule obj.Quota_SwapPercent_Lower = swapPercentSetting?.QuotaLowerLimit ?? double.NaN; obj.Quota_SwapPercent_wUpper = swapPercentSetting?.WarningUpperLimit ?? double.NaN; obj.Quota_SwapPercent_wLower = swapPercentSetting?.WarningLowerLimit ?? double.NaN; + var dvSetting = temp.Where(O => O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); + obj.Quota_DV_Upper = dvSetting?.QuotaUpperLimit ?? double.NaN; + obj.Quota_DV_Lower = dvSetting?.QuotaLowerLimit ?? double.NaN; + obj.Quota_DV_wUpper = dvSetting?.WarningUpperLimit ?? double.NaN; + obj.Quota_DV_wLower = dvSetting?.WarningLowerLimit ?? double.NaN; }); var setting = QueryCurrentQuotaSetting(QuotaTypeEnum.TRADE); setting = MargeQuotaSetting(setting, 0, 0); var ccrDict = new Dictionary(); + + // 批量获取swap_position方向/多空/数量,用于DV计算 + var tradeIds = list.Select(O => O.trade.id).Distinct().ToList(); + var swapPositionDict = DbContext.swap_position + .Where(p => tradeIds.Contains(p.SwapTradeId) && !p.IsInitial && p.PosiDirection > 0 && !p.Invalid) + .ToDictionary(p => p.SwapTradeId); + foreach (var item in list) { setValue(item, setting); @@ -1337,6 +1410,13 @@ namespace YLErp.Modules.RiskModule var vobp = bondPrice == null ? 0 : Convert.ToDouble(bondPrice.Vobp); var pricePercent = basePrice == 0 ? 0 : Math.Abs((sportPrice / basePrice) - 1); item.Quota_SwapPercent = pricePercent; + + // 计算DV + if (swapPositionDict.TryGetValue(item.trade.id, out var swapPos) && bondPrice != null && vobp != 0) + { + item.DV = Convert.ToDouble(Dv01Helper.CalcDv01(item.trade.UnderlyingCode, swapPos.PosiQuantity, swapPos.PosiDirection, swapPos.PositionType, Convert.ToDecimal(vobp))); + } + var obj = new QuotaMonitor_Trade(); ObjectHelper.MapValues(obj, item); result.Add(obj); @@ -1351,6 +1431,7 @@ namespace YLErp.Modules.RiskModule VegaCash = result.Sum(O => O.VegaCash), Quota_CCR = result.Sum(O => O.Quota_CCR), PnL = result.Sum(O => O.PnL), + DV = result.Sum(O => O.DV), }; result.Add(total); @@ -1430,11 +1511,26 @@ namespace YLErp.Modules.RiskModule obj.Quota_SwapPercent_Lower = swapPercentSetting?.QuotaLowerLimit ?? double.NaN; obj.Quota_SwapPercent_wUpper = swapPercentSetting?.WarningUpperLimit ?? double.NaN; obj.Quota_SwapPercent_wLower = swapPercentSetting?.WarningLowerLimit ?? double.NaN; + var dvSetting = temp.Where(O => O.QuotaIndex == "DV").FirstOrDefault()?.Clone(); + obj.Quota_DV_Upper = dvSetting?.QuotaUpperLimit ?? double.NaN; + obj.Quota_DV_Lower = dvSetting?.QuotaLowerLimit ?? double.NaN; + obj.Quota_DV_wUpper = dvSetting?.WarningUpperLimit ?? double.NaN; + obj.Quota_DV_wLower = dvSetting?.WarningLowerLimit ?? double.NaN; }); var setting = QueryCurrentQuotaSetting(QuotaTypeEnum.TRADE); setting = MargeQuotaSetting(setting, 0, 0); var ccrDict = new Dictionary(); + + // 批量获取eod_swap_position方向/多空/数量,用于DV计算和dv01回写 + var tradeIds = list.Select(O => O.trade.id).Distinct().ToList(); + var eodSwapPosDict = DbContext.eod_swap_position + .Where(p => p.ValueDate == req.ValueDate && tradeIds.Contains(p.SwapTradeId) && p.PosiQuantity > 0) + .ToDictionary(p => p.SwapTradeId); + var eodSwapDict = DbContext.eod_swap + .Where(p => p.ValueDate == req.ValueDate && tradeIds.Contains(p.SwapTradeId)) + .ToDictionary(p => p.SwapTradeId); + foreach (var item in list) { setValue(item, setting); @@ -1444,10 +1540,18 @@ namespace YLErp.Modules.RiskModule var vobp = bondPrice == null ? 0 : Convert.ToDouble(bondPrice.Vobp); var pricePercent = basePrice == 0 ? 0 : Math.Abs((sportPrice / basePrice) - 1); item.Quota_SwapPercent = pricePercent; + + // 计算DV,并回写dv01 + if (eodSwapPosDict.TryGetValue(item.trade.id, out var eodSwapPos)) + { + item.DV = Convert.ToDouble(eodSwapPos.dv01); + } + var obj = new QuotaMonitor_Trade(); ObjectHelper.MapValues(obj, item); result.Add(obj); } + var total = new QuotaMonitor_Trade() { TradeNumber = "合计", @@ -1458,6 +1562,7 @@ namespace YLErp.Modules.RiskModule VegaCash = result.Sum(O => O.VegaCash), Quota_CCR = result.Sum(O => O.Quota_CCR), PnL = result.Sum(O => O.PnL), + DV = result.Sum(O => O.DV), }; result.Add(total); @@ -3522,6 +3627,20 @@ namespace YLErp.Modules.RiskModule singleUnderlyingRate = valuedateBLL.SystemDate.SingleUnderlyingRate; _quotaSettings = new List(); #region 整体业务 + //全局-DV + _quotaSettings.Add(new QuotaSetting() + { + QuotaType = QuotaTypeEnum.GLOBAL_ALL, + QuotaRange = 0, + QuotaIndex = "DV", + QuotaLowerLimit = null, + QuotaUpperLimit = null, + WarningLowerLimit = null, + WarningUpperLimit = null, + Percent = false, + IsValid = true, + Status = QuotaSettingApprovalStatus.Valid, + }); #endregion #region 互换 @@ -3617,6 +3736,20 @@ namespace YLErp.Modules.RiskModule IsValid = true, Status = QuotaSettingApprovalStatus.Valid, }); + //交易-DV + _quotaSettings.Add(new QuotaSetting() + { + QuotaType = QuotaTypeEnum.TRADE, + QuotaRange = 0, + QuotaIndex = "DV", + QuotaLowerLimit = null, + QuotaUpperLimit = null, + WarningLowerLimit = null, + WarningUpperLimit = null, + Percent = false, + IsValid = true, + Status = QuotaSettingApprovalStatus.Valid, + }); #endregion #region 客户 @@ -4335,7 +4468,7 @@ namespace YLErp.Modules.RiskModule return QuotaCheck(ref res, false); } - public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) + public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning) { if (res == null || res.Trade == null) { @@ -4368,6 +4501,7 @@ namespace YLErp.Modules.RiskModule new TradeRiskCheckLogService(UserInfo).AddLog(quotaObj); } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; + res.RetCode = TradeOpenRetCode.QuotaTrialError; res.TrialDataId = quotaObj.id; //汇总各类检查详情,避免只展示RiskWarningDetails而漏掉其它老风控检查 var detailParts = new List(); @@ -4381,27 +4515,14 @@ namespace YLErp.Modules.RiskModule detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); res.ErrorMsg = string.Join("\n", detailParts); //确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。 - var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.RiskWarning - && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails) - && (quotaObj.ApprovalRuleIds?.Any() ?? false); - //当前为老风控特批,不是新风控需审批 - res.OldRiskNeedSpecialApproval = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning; - if (isRiskApprovalWarning) + var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning + && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); + //需清除ErrorMsg,不然外部调用会认为失败 + //触发需审批,交易特批逻辑 + if (ignoreRiskWarning && isRiskApprovalWarning) { - var currentApprovalRuleIds = quotaObj.ApprovalRuleIds ?? new List(); - res.IgnoredRiskRuleIds = currentApprovalRuleIds; - //需清除ErrorMsg,不然外部调用会认为失败 - //触发需审批,交易特批逻辑 - if (ignoreRiskWarning) - { - var ignoredRuleIdSet = new HashSet((ignoreRiskRuleIds ?? Enumerable.Empty()).Where(o => !string.IsNullOrWhiteSpace(o))); - var newApprovalRuleIds = currentApprovalRuleIds.Where(o => !ignoredRuleIdSet.Contains(o)).ToList(); - if (!newApprovalRuleIds.Any()) - { - res.ErrorMsg = string.Empty; - return true; - } - } + res.ErrorMsg = string.Empty; + return true; } res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; return false; @@ -4440,6 +4561,9 @@ namespace YLErp.Modules.RiskModule var ums = GetUnderlyings(umCodes); var checkPoisiList = BuildCheckQuotaMoitorModels(clientPositions, ums, dealDate); + // 仅在限额设置中有DV指标时才计算DV + //var (totalDv, currentDv) = precheckQuotaSettingList.Any(s => s.QuotaIndex == "DV") + // ? CalcRiskCheckDv(checkPoisiList, dealDate) : (0d, 0d); var underlyingPositions = checkPoisiList.Where(x => x.UnderlyingCode == clientRiskCheckReq.securityId).ToList(); var sameQty = underlyingPositions.Where(x=>x.ClientId== clientRiskCheckReq.clientId && x.Side== clientRiskCheckReq.side).Sum(s => s.Qty); var qty = underlyingPositions.Where(x => x.ClientId == clientRiskCheckReq.clientId && x.Side != clientRiskCheckReq.side).Sum(s => s.Qty); @@ -4467,7 +4591,7 @@ namespace YLErp.Modules.RiskModule { continue; } - clientRiskCheckItem = CheckQuota(checkPoisiList, settingItem.QuotaType, clientRiskCheckItem, settingItem.QuotaRange, allList); + clientRiskCheckItem = CheckQuota(checkPoisiList, settingItem.QuotaType, clientRiskCheckItem, settingItem.QuotaRange, allList, 0d, 0d); if (clientRiskCheckItem != null) { clientRiskCheckItem.quotaType = $"{EnumHelper.GetDescriptionByName(settingItem.QuotaType)}({settingItem.QuotaIndex})"; @@ -4499,6 +4623,50 @@ namespace YLErp.Modules.RiskModule .Where(x => umCodes.Contains(x.UnderlyingCode)) .ToList(); } + /// + /// 计算风控检查用的DV(全局DV + 当前单笔DV) + /// + private (double totalDv, double currentDv) CalcRiskCheckDv(List checkPoisiList, DateTime dealDate) + { + var totalDv = 0d; + var currentDv = 0d; + // 按标的缓存bondPrice + var bpCache = checkPoisiList.Select(p => p.UnderlyingCode).Distinct() + .ToDictionary(code => code, code => EodPriceQueryService.GetBondPrice(dealDate, code)); + // 全部持仓DV + totalDv = checkPoisiList.Sum(pos => + { + if (!bpCache.TryGetValue(pos.UnderlyingCode, out var bp) || bp == null) return 0d; + var vobp = bp.Vobp ?? 0; + if (vobp == 0) return 0d; + int posType = pos.Side == 0 ? (int)PositionTypeFlag.Long : (int)PositionTypeFlag.Short; + return Convert.ToDouble(Dv01Helper.CalcDv01(pos.UnderlyingCode, pos.Qty, pos.Direction, posType, vobp)); + }); + // 当前单笔DV + var cur = checkPoisiList.FirstOrDefault(x => x.Current); + if (cur != null && bpCache.TryGetValue(cur.UnderlyingCode, out var curBp) && curBp != null) + { + var vobp = curBp.Vobp ?? 0; + if (vobp != 0) + { + int pt = cur.Side == 0 ? (int)PositionTypeFlag.Long : (int)PositionTypeFlag.Short; + currentDv = Convert.ToDouble(Dv01Helper.CalcDv01(cur.UnderlyingCode, cur.Qty, cur.Direction, pt, vobp)); + } + } + // 标的交易DV + List tradeTypes = new List { "利率债", "信用债", "其它债券" }; + var tposis = DbContext.TradePosition.Where(x => tradeTypes.Contains(x.TradeType)).AsNoTracking().ToList(); + foreach (var code in tposis.Select(x => x.UnderlyingCode).Distinct().Where(c => !bpCache.ContainsKey(c))) + bpCache[code] = EodPriceQueryService.GetBondPrice(dealDate, code); + foreach (var item in tposis) + { + if (!bpCache.TryGetValue(item.UnderlyingCode, out var tpBp) || tpBp == null) continue; + var vobp = tpBp.Vobp ?? 0; + if (vobp == 0) continue; + totalDv += Convert.ToDouble(Dv01Helper.CalcDv01(item.UnderlyingCode, Convert.ToDecimal(Math.Abs(item.Position)), (int)SwapDirectionEnum.收取, (int)item.PositionType, vobp)); + } + return (totalDv, currentDv); + } private (List, List) GetClientPositionsAndOrders(BondOmsDBContext bondDb, long orderId) { var clientPositions = bondDb.client_position.AsNoTracking().ToList(); @@ -4944,8 +5112,10 @@ namespace YLErp.Modules.RiskModule checkQuotaMoitorModel.Current = checkQuotaMoitorModel.id == 0; checkPoisiList.Add(checkQuotaMoitorModel); } - quotaWarningStatus = CheckQuota(checkPoisiList, posiList, list, allList, true, out quotaWarningMsg); - quotaStatus = CheckQuota(checkPoisiList, posiList, list, allList, false, out quotaMsg); + // 预计算DV,避免warning/error两次检查各算一遍 + var (quotaTotalDv, quotaCurrentDv) = list.Any(s => s.QuotaIndex == "DV") ? CalcRiskCheckDv(checkPoisiList, tradeObj.TradeDate.Value) : (0d, 0d); + quotaWarningStatus = CheckQuota(checkPoisiList, posiList, list, allList, true, out quotaWarningMsg, quotaTotalDv, quotaCurrentDv); + quotaStatus = CheckQuota(checkPoisiList, posiList, list, allList, false, out quotaMsg, quotaTotalDv, quotaCurrentDv); } } } @@ -4972,6 +5142,8 @@ namespace YLErp.Modules.RiskModule result.RiskWarningDetails = riskWarningMsg ?? ""; result.QuotaWarningDetails = quotaWarningMsg ?? ""; result.AvailableForClient = availableMsg ?? ""; + result.TrialSource = trialSource; + _logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); // ===== 新风控引擎接入点(设计文档 4.6.1) ===== try @@ -4982,6 +5154,7 @@ namespace YLErp.Modules.RiskModule TradeId = tradeId, TriggerPoint = "BOOK_CONFIRM" }; + tradeObj.StockEqvNotional = 10000000000; riskContext.DataMap["trade"] = tradeObj; var sameUnderlyingTotalNotional = DbContext.trade .Where(t => t.ValidState != "InValid" @@ -5021,25 +5194,12 @@ namespace YLErp.Modules.RiskModule } if (riskResult.NeedApproval) { - if (result.TrialStatus != QuotaTrialStatusEnum.Error && result.TrialStatus != QuotaTrialStatusEnum.Warning) + if (result.TrialStatus != QuotaTrialStatusEnum.Error) { - result.TrialStatus = QuotaTrialStatusEnum.RiskWarning; + result.TrialStatus = QuotaTrialStatusEnum.Warning; } result.RiskWarningDetails += "[风控引擎] 规则触发:需审批\n"; - var approvalTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval).ToList(); - var approvalTriggeredRuleIds = approvalTriggeredRules.Select(r => r.RuleId) - .Where(r => !string.IsNullOrWhiteSpace(r)) - .ToList(); - var showTipTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip).ToList(); - var showTipTriggeredRuleIds = showTipTriggeredRules.Select(r => r.RuleId) - .Where(r => !string.IsNullOrWhiteSpace(r)) - .ToList(); - // 二次确认时,需审批规则允许按首次命中结果忽略;同时已展示过的提示规则也不再重复展示 - var processedRiskRuleIds = approvalTriggeredRuleIds.Concat(showTipTriggeredRuleIds) - .Distinct() - .ToList(); - result.ApprovalRuleIds = processedRiskRuleIds; - foreach (var triggeredRule in approvalTriggeredRules) + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval)) { result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; } @@ -5064,7 +5224,6 @@ namespace YLErp.Modules.RiskModule } // ===== 新风控引擎接入结束 ===== - //_logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); SaveQuotaTrial(result); return result; } @@ -5435,7 +5594,7 @@ namespace YLErp.Modules.RiskModule /// 试算类型是预警还是警告 /// 试算结果描述 /// - private bool CheckQuota(List positionList, List posiList, List setting, List settingAll, bool warning, out string msg) + private bool CheckQuota(List positionList, List posiList, List setting, List settingAll, bool warning, out string msg, double totalDv = 0, double currentDv = 0) { msg = ""; var msgList = new List(); @@ -5445,11 +5604,14 @@ namespace YLErp.Modules.RiskModule { switch (item.Key.QuotaType) { + case QuotaTypeEnum.GLOBAL_ALL: + msgList.AddRange(checkGlobalDv(positionList, item.Value, warning, totalDv)); + break; case QuotaTypeEnum.GLOBAL_SWAP: msgList.AddRange(checkGlobal(positionList, posiList, "场外业务-互换", item.Value, warning)); break; case QuotaTypeEnum.TRADE: - msgList.AddRange(checkTrade(positionList, posiList, "单笔交易", item.Value, warning)); + msgList.AddRange(checkTrade(positionList, posiList, "单笔交易", item.Value, warning, currentDv)); break; case QuotaTypeEnum.UNDERLYING: msgList.AddRange(checkUnderlying(positionList, posiList, "标的资产", item.Value, settingAll, warning)); @@ -5503,17 +5665,25 @@ namespace YLErp.Modules.RiskModule /// /// 单笔名义本金 /// - private ClientRiskCheckItem CheckQuota(List positionList, QuotaTypeEnum quotaType, ClientRiskCheckItem checkItem, int quotaRange, List settingAll) + private ClientRiskCheckItem CheckQuota(List positionList, QuotaTypeEnum quotaType, ClientRiskCheckItem checkItem, int quotaRange, List settingAll, double totalDv = 0, double currentDv = 0) { var tag_prefix = ""; var positionListAll = positionList; switch (quotaType) { + //case QuotaTypeEnum.GLOBAL_ALL: + // if (checkItem.quotaType == "DV") + // { + // checkItem.currentValue = Math.Round(totalDv, 2); + // if (!ValidateQuoteResult(checkItem)) return checkItem; + // return null; + // } + // break; case QuotaTypeEnum.GLOBAL_SWAP: checkItem = checkGlobal(positionList, "场外业务-互换", checkItem, quotaType); break; case QuotaTypeEnum.TRADE: - checkItem = checkTrade(positionList, "单笔交易", checkItem); + checkItem = checkTrade(positionList, "单笔交易", checkItem, currentDv); break; case QuotaTypeEnum.UNDERLYING: checkItem = checkUnderlying(positionList, "标的资产", checkItem, quotaRange, settingAll); @@ -5548,7 +5718,8 @@ namespace YLErp.Modules.RiskModule BusinessType = "标的交易", PositionPnl=0, DeltaExposure=0, - StockEqvNotional=0 + StockEqvNotional=0, + DV=0 }; List tradetypes = new List { "利率债", "信用债", "其它债券" }; var tposis = DbContext.TradePosition.Where(x => tradetypes.Contains(x.TradeType)).AsNoTracking().ToList(); @@ -5570,6 +5741,12 @@ namespace YLErp.Modules.RiskModule { var bondPrice = EodPriceQueryService.GetBondPrice(valuedateBLL.ValueDate, item.UnderlyingCode); lastPrice = bondPrice != null ? bondPrice.ClosePrice : (um.Price ?? 0) * Convert.ToDouble(ConsGlobal.bondPriceMultiple); + // 计算标的交易DV + var vobp = bondPrice != null ? bondPrice.Vobp ?? 0 : 0; + if (vobp != 0) + { + quotaMonitor_Global.DV += Convert.ToDouble(Dv01Helper.CalcDv01(item.UnderlyingCode,Math.Abs(Convert.ToDecimal(item.Position)), (int)SwapDirectionEnum.收取, (int)item.PositionType, vobp)); + } } } quotaMonitor_Global.PositionPnl += lastPrice * item.Position - item.PositionCost; @@ -5592,7 +5769,8 @@ namespace YLErp.Modules.RiskModule BusinessType = "标的交易", PositionPnl = 0, DeltaExposure = 0, - StockEqvNotional = 0 + StockEqvNotional = 0, + DV = 0 }; List tradetypes = new List { "利率债", "信用债", "其它债券" }; var tposis = DbContext.eod_trade_position.Where(x => tradetypes.Contains(x.TradeType)&&x.ValueDate==valueDate).AsNoTracking().ToList(); @@ -5614,6 +5792,13 @@ namespace YLErp.Modules.RiskModule { var bondPrice = EodPriceQueryService.GetBondPrice(valuedateBLL.ValueDate, item.UnderlyingCode); lastPrice = bondPrice != null ? bondPrice.ClosePrice : (um.Price ?? 0) * Convert.ToDouble(ConsGlobal.bondPriceMultiple); + // 计算标的交易DV: Amount有符号(正=多,负=空) + var vobp = bondPrice != null ? bondPrice.Vobp ?? 0 : 0; + if (vobp != 0) + { + int positionType = item.Amount > 0 ? (int)PositionTypeFlag.Long : (int)PositionTypeFlag.Short; + quotaMonitor_Global.DV += Convert.ToDouble(Dv01Helper.CalcDv01(item.UnderlyingCode, Convert.ToDecimal(Math.Abs(item.Amount)), (int)SwapDirectionEnum.收取, positionType, vobp)); + } } } quotaMonitor_Global.PositionPnl += item.PositionPnL; @@ -5677,6 +5862,27 @@ namespace YLErp.Modules.RiskModule } return messageList; } + /// + /// 检验全局DV限额 + /// + private List checkGlobalDv(List positionList, QuotaSetting[] settings, bool warning, double totalDv = -1) + { + var messageList = new List(); + var dvSettings = settings.Where(s => s.QuotaIndex == "DV").ToArray(); + if (!dvSettings.Any()) return messageList; + // totalDv未预计算时(其他调用方),才在这里算 + if (totalDv < 0) + totalDv = CalcRiskCheckDv(positionList, valuedateBLL.ValueDate).totalDv; + foreach (var settingItem in dvSettings) + { + double? upperLimit = warning ? settingItem.WarningUpperLimit : settingItem.QuotaUpperLimit; + double? lowerLimit = warning ? settingItem.WarningLowerLimit : settingItem.QuotaLowerLimit; + if (upperLimit == null && lowerLimit == null) continue; + var tag = $"全局(DV)"; + messageList.Add(SetMsg(tag, totalDv, upperLimit, lowerLimit, settingItem.Percent, warning)); + } + return messageList; + } private ClientRiskCheckItem checkGlobal(List positionList, string tag_prefix, ClientRiskCheckItem checkItem, QuotaTypeEnum quotaType) { @@ -5711,7 +5917,7 @@ namespace YLErp.Modules.RiskModule } return null; } - private List checkTrade(List positionList, List posiList, string tag_prefix, QuotaSetting[] settings, bool warning) + private List checkTrade(List positionList, List posiList, string tag_prefix, QuotaSetting[] settings, bool warning, double currentDv = -1) { if (string.IsNullOrEmpty(tag_prefix)) { @@ -5727,6 +5933,9 @@ namespace YLErp.Modules.RiskModule double? upperLimit, lowerLimit, currentValue = null; CheckQuotaMoitorModel current = positionList.FirstOrDefault(x => x.Current); + // currentDv未预计算时(其他调用方),才在这里算 + if (currentDv < 0 && settings.Any(s => s.QuotaIndex == "DV")) + currentDv = CalcRiskCheckDv(positionList, valuedateBLL.ValueDate).currentDv; foreach (var settingItem in settings) { if (warning) @@ -5769,6 +5978,10 @@ namespace YLErp.Modules.RiskModule currentValue = Convert.ToDouble(current.Delta); messageList.Add(SetMsg(tag, currentValue, upperLimit, lowerLimit, settingItem.Percent, warning)); break; + case "DV": + currentValue = currentDv; + messageList.Add(SetMsg(tag, currentValue, upperLimit, lowerLimit, settingItem.Percent, warning)); + break; default: continue; } @@ -5776,7 +5989,7 @@ namespace YLErp.Modules.RiskModule } return messageList; } - private ClientRiskCheckItem checkTrade(List positionList, string tag_prefix, ClientRiskCheckItem checkItem) + private ClientRiskCheckItem checkTrade(List positionList, string tag_prefix, ClientRiskCheckItem checkItem, double currentDv = 0) { if (string.IsNullOrEmpty(tag_prefix)) { @@ -5808,10 +6021,18 @@ namespace YLErp.Modules.RiskModule return checkItem; } return null; + //case "DV": + // currentValue = currentDv; + // break; default: return null; } - + checkItem.currentValue = Math.Round(currentValue ?? 0, 2); + if (!ValidateQuoteResult(checkItem)) + { + return checkItem; + } + return null; } private List checkUnderlying(List positionList, List posiList, string tag_prefix, QuotaSetting[] settings, List settingAll, bool warning) From 8ab62928b604952b4befc744b834eac4d6b6ef91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Tue, 7 Jul 2026 13:57:38 +0800 Subject: [PATCH 08/20] revert otcformat.js --- YLErpWeb/App_Data/Config/otcformat.js | 125 +------------------------- 1 file changed, 1 insertion(+), 124 deletions(-) diff --git a/YLErpWeb/App_Data/Config/otcformat.js b/YLErpWeb/App_Data/Config/otcformat.js index d4c79353..92b1f226 100644 --- a/YLErpWeb/App_Data/Config/otcformat.js +++ b/YLErpWeb/App_Data/Config/otcformat.js @@ -1,125 +1,2 @@ var main = main || {}; -main.formatOptions={ - "trading": { - "umprice": { - "precision": 9, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 9, - "maxDecimals": 0 - }, - "umpriceP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "umpricePR": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "tradeSinglePrice": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "premiumRateP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "premiumRate": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "tradePrice": { - "precision": 2, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "StockEqvNotional": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "notional": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "notionalP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "volatility": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "volatilityP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "greek": { - "precision": 2, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "marginRateP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "marginRate": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - } - } -}; \ No newline at end of file +main.formatOptions = { "trading": { "umprice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "umpriceP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "umpricePR": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradeSinglePrice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "premiumRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "premiumRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradePrice": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "StockEqvNotional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notionalP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "volatility": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "volatilityP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "greek": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "marginRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "marginRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 } } }; \ No newline at end of file From 2a8544a7b7959753223789bd757bb24b646d59fc Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 6 Jul 2026 18:00:06 +0800 Subject: [PATCH 09/20] =?UTF-8?q?fix:=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 677dde69..9ce485ad 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5154,7 +5154,6 @@ namespace YLErp.Modules.RiskModule TradeId = tradeId, TriggerPoint = "BOOK_CONFIRM" }; - tradeObj.StockEqvNotional = 10000000000; riskContext.DataMap["trade"] = tradeObj; var sameUnderlyingTotalNotional = DbContext.trade .Where(t => t.ValidState != "InValid" From 879fdbea89024b7099e7d8225639554779dbe3c9 Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 6 Jul 2026 17:41:13 +0800 Subject: [PATCH 10/20] resolve confilct QuotaMonitorService.cs --- .../Modules/RiskModule/QuotaMonitorService.cs | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 9ce485ad..0b05ceee 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4468,7 +4468,7 @@ namespace YLErp.Modules.RiskModule return QuotaCheck(ref res, false); } - public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning) + public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) { if (res == null || res.Trade == null) { @@ -4515,14 +4515,27 @@ namespace YLErp.Modules.RiskModule detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); res.ErrorMsg = string.Join("\n", detailParts); //确认本次为需审批后,二次特批。因为只靠点击“交易特批”的ignoreRiskWarning,不能保证本次校验通过。 - var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning - && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); - //需清除ErrorMsg,不然外部调用会认为失败 - //触发需审批,交易特批逻辑 - if (ignoreRiskWarning && isRiskApprovalWarning) + var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.RiskWarning + && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails) + && (quotaObj.ApprovalRuleIds?.Any() ?? false); + //当前为老风控特批,不是新风控需审批 + res.OldRiskNeedSpecialApproval = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning; + if (isRiskApprovalWarning) { - res.ErrorMsg = string.Empty; - return true; + var currentApprovalRuleIds = quotaObj.ApprovalRuleIds ?? new List(); + res.IgnoredRiskRuleIds = currentApprovalRuleIds; + //需清除ErrorMsg,不然外部调用会认为失败 + //触发需审批,交易特批逻辑 + if (ignoreRiskWarning) + { + var ignoredRuleIdSet = new HashSet((ignoreRiskRuleIds ?? Enumerable.Empty()).Where(o => !string.IsNullOrWhiteSpace(o))); + var newApprovalRuleIds = currentApprovalRuleIds.Where(o => !ignoredRuleIdSet.Contains(o)).ToList(); + if (!newApprovalRuleIds.Any()) + { + res.ErrorMsg = string.Empty; + return true; + } + } } res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; return false; @@ -5193,12 +5206,25 @@ namespace YLErp.Modules.RiskModule } if (riskResult.NeedApproval) { - if (result.TrialStatus != QuotaTrialStatusEnum.Error) + if (result.TrialStatus != QuotaTrialStatusEnum.Error && result.TrialStatus != QuotaTrialStatusEnum.Warning) { - result.TrialStatus = QuotaTrialStatusEnum.Warning; + result.TrialStatus = QuotaTrialStatusEnum.RiskWarning; } result.RiskWarningDetails += "[风控引擎] 规则触发:需审批\n"; - foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval)) + var approvalTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval).ToList(); + var approvalTriggeredRuleIds = approvalTriggeredRules.Select(r => r.RuleId) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + var showTipTriggeredRules = riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip).ToList(); + var showTipTriggeredRuleIds = showTipTriggeredRules.Select(r => r.RuleId) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + // 二次确认时,需审批规则允许按首次命中结果忽略;同时已展示过的提示规则也不再重复展示 + var processedRiskRuleIds = approvalTriggeredRuleIds.Concat(showTipTriggeredRuleIds) + .Distinct() + .ToList(); + result.ApprovalRuleIds = processedRiskRuleIds; + foreach (var triggeredRule in approvalTriggeredRules) { result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; } From b8fb8bb148be787089d3dd9e3c724eae7d186409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Tue, 7 Jul 2026 15:25:27 +0800 Subject: [PATCH 11/20] resolve confilct YLContext.cs & YLErpDAL.csproj --- YLErpDAL/DataBase/YLContext.cs | 5 +++++ YLErpDAL/YLErpDAL.csproj | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index b6ed6e6d..78936a0c 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -408,5 +408,10 @@ namespace YLErp.BLL public DbSet tradeContractOaResult { get; set; } public DbSet bondPayment { get; set; } + public DbSet glms_risk_rule { get; set; } + public DbSet glms_risk_rule_application { get; set; } + public DbSet glms_risk_rule_audit_log { get; set; } + public DbSet glms_risk_variable { get; set; } + } } \ No newline at end of file diff --git a/YLErpDAL/YLErpDAL.csproj b/YLErpDAL/YLErpDAL.csproj index 83cf8334..8b50f99f 100644 --- a/YLErpDAL/YLErpDAL.csproj +++ b/YLErpDAL/YLErpDAL.csproj @@ -30,6 +30,8 @@ + + @@ -40,7 +42,7 @@ - + From fc51bfab68c8ac06f5a33d24588584e88abbcfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Wed, 8 Jul 2026 10:59:13 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=A2=ABrevert?= =?UTF-8?q?=E8=AF=AF=E6=93=8D=E4=BD=9C=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 14 ++++++++++++++ YLErpWeb/App/HostedTaskService.cs | 13 +++++++++++++ YLErpWeb/App_Data/FunctionRight.xml | 17 +++++++++++++++++ YLErpWeb/App_Data/Menus.txt | 1 + 4 files changed, 45 insertions(+) diff --git a/.editorconfig b/.editorconfig index 40eb71f1..e2964ef3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -160,3 +160,17 @@ csharp_preserve_single_line_statements = true # CA1819: Properties should not return arrays dotnet_diagnostic.CA1819.severity = silent + +# JavaScript 和 TypeScript 文件 +[*.{js,jsx,ts,tsx}] +# 缩进和间距 +indent_size = 4 +indent_style = space +tab_width = 4 + +# 新行首选项 +end_of_line = crlf +insert_final_newline = false + +# 拖尾逗号不添加 +trailing_comma = none diff --git a/YLErpWeb/App/HostedTaskService.cs b/YLErpWeb/App/HostedTaskService.cs index 75bf1339..025420b8 100644 --- a/YLErpWeb/App/HostedTaskService.cs +++ b/YLErpWeb/App/HostedTaskService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using YLErp.DBModels.Consts; using YLErp.Modules.AppModule; using YLErp.Modules.EodModule.SettlementModule; +using YLErp.Modules.RiskEngine; using YLErp.Modules.RiskModule; using YLErp.Modules.SuperviseReportModule.SAC.Service; using YLErp.Modules.TradeRiskCalcModule; @@ -124,6 +125,18 @@ namespace YLErp.Web.App PS.SetConfig(ConsAppConfig.YLErpWebUrlConfig, address); } + // 预热风控引擎:加载规则与应用到内存,并预编译所有规则 + Task.Run(() => + { + try + { + RiskEngineService.GetInstance().Preload(); + } + catch (Exception ex) + { + LogFactory.GetLogger("HostedTaskService").Error("[风控引擎] 启动预热失败", ex); + } + }); }); return Task.CompletedTask; diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index 7962c464..3d23be45 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -60,6 +60,23 @@ + + + + + + + + + + + + + + + + + diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index a8aea562..506f2a79 100644 --- a/YLErpWeb/App_Data/Menus.txt +++ b/YLErpWeb/App_Data/Menus.txt @@ -25,6 +25,7 @@ {Name:"资金监控",Rights:["风险控制-资金监控"],Url:"client/clientRiskMonitor"}, {Name:"市场风险",Rights:["风险控制-市场风险"],Url:"risk/RiskExposureReport"}, {Name:"限额监控",Rights:["风险控制-限额监控"],Url:"risk/quotaMonitor"}, + {Name:"异常交易监控",Rights:["风险控制-异常交易监控"],Url:"v3/risk/risk-engine-config"}, {Name:"白名单券池",Rights:["风险控制-白名单券池"],Url:"v3/data/underlying-pool"}, {Name:"日终持仓风险",Rights:["风险控制-日终持仓风险"],Url:"trade/EodPositionRisks"}, {Name:"日终持仓风险_互换",Rights:["风险控制-日终持仓风险_互换"],Url:"swaptrade2/EodPositionRisks"}, From d14bdc18e7a4c637524d30c590bdcff519eaa89e Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 9 Jul 2026 10:26:26 +0800 Subject: [PATCH 13/20] =?UTF-8?q?feat:=E9=A3=8E=E6=8E=A7=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B6=85=E6=97=B6=E6=9C=BA=E5=88=B6=EF=BC=9A?= =?UTF-8?q?=E5=9C=A8=E8=BF=9B=E8=A1=8C=E8=B6=85=E6=97=B6=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=9A=84=E6=97=B6=E5=80=99=EF=BC=8C=E5=A6=82=E6=9E=9C=E8=B6=85?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E4=B8=8D=E4=BD=86=E9=87=8D=E7=BD=AE=E6=96=B0?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7=E9=87=8D=E6=96=B0=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E8=80=81=E9=A3=8E=E6=8E=A7=E7=9A=84=E4=BA=8C=E6=AC=A1=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B9=9F=E4=B8=8D=E5=85=81=E8=AE=B8=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=EF=BC=8C=E9=9C=80=E8=A6=81=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskModule/QuotaMonitorService.cs | 16 ++++++++++ .../RiskModule/TradeRiskCheckLogService.cs | 20 +++++++++++++ YLErpWeb/Controllers/tradeController.cs | 29 ++++++++++++++++++- .../Scripts/app/swaptrade/swapTradeView.js | 8 +++-- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 0b05ceee..41b62b1b 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4929,6 +4929,22 @@ namespace YLErp.Modules.RiskModule return result; } + /// + /// 按 quotaTrial.id 校验试算结果是否已超过配置时效 + /// + /// quotaTrial.id + /// 有效时长(秒) + /// 查不到记录或已超时返回 true,否则返回 false + public bool IsQuotaTrialExpired(int trialDataId, int expireSeconds) + { + var quotaTrial = QueryQuotaTrial(trialDataId); + if (quotaTrial == null || !quotaTrial.OptDate.HasValue) + { + return true; + } + return DateTime.Now - quotaTrial.OptDate.Value > TimeSpan.FromSeconds(expireSeconds); + } + /// /// 查询试算历史 /// diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index a5d508d8..96d6303e 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -149,6 +149,26 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + /// + /// 查询指定交易最近一次新风控“确认通过”的处理时间,用于控制二次审批放行仅在短时间内有效。 + /// + public DateTime? QueryLatestRiskWarningDecisionTime(int tradeId) + { + var tradeNumber = DbContext.trade.Where(x => x.id == tradeId).Select(x => x.TradeNumber).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(tradeNumber)) + { + return null; + } + + return DbContext.trade_risk_check_log + .Where(x => x.trade_number == tradeNumber + && x.trial_result == (int)QuotaTrialStatusEnum.RiskWarning + && x.remark.Contains("风控预警处理结果:确认通过")) + .OrderByDescending(x => x.create_time) + .Select(x => (DateTime?)x.create_time) + .FirstOrDefault(); + } + public void AddWarningDecisionLog(int quotaTrialId, string decision) { var quotaTrial = DbContext.quotaTrial.FirstOrDefault(x => x.id == quotaTrialId); diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 98a3d539..34c01daa 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2447,7 +2447,8 @@ namespace YLErp.Web.Controllers } - public JsonResult tradeConfirm(string tradeids, string additionalProcessing, string ignoreRiskRuleIds, bool isSkipCheck = false) + // trialDataId: 二次确认时回传的 quotaTrial.id,用于超时校验;首次确认不传,为 null 时不做超时判断 + public JsonResult tradeConfirm(string tradeids, string additionalProcessing, string ignoreRiskRuleIds, int? trialDataId, bool isSkipCheck = false) { var tradeidArr = DataConvert.ConvertCommaValuesToInt32Array(tradeids); if (tradeids is null || !tradeids.Any(n => n > 0)) @@ -2455,9 +2456,35 @@ namespace YLErp.Web.Controllers return JsonError("请至少选择一项要生成确认书的交易,参数tradeids:" + tradeids); } var config = valuedateBLL.SystemDate; + var riskWarningConfirmExpireSeconds = 300; + var riskWarningConfirmExpireSecondsConfig = AppManager.GetAppConfigValue("ProjectConfig", "Trade.RiskWarningConfirmExpireSeconds"); + if (!string.IsNullOrWhiteSpace(riskWarningConfirmExpireSecondsConfig) + && int.TryParse(riskWarningConfirmExpireSecondsConfig, out var configuredRiskWarningConfirmExpireSeconds) + && configuredRiskWarningConfirmExpireSeconds > 0) + { + riskWarningConfirmExpireSeconds = configuredRiskWarningConfirmExpireSeconds; + } var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); + // 老风控交易特批与新风控二次审批统一基于 quotaTrial 做超时校验,超时后清空放行标记并重新校验。 + // 仅 swapTradeView.js 会回传 trialDataId 并触发 RiskWarningConfirm,按 quotaTrial.OptDate 精确校验; + // tradeview.js / tradeConfirmList.js / swapTradeConfirmList.js / quotaMonitor.js 只发 LackOfMoney,不传 trialDataId,不进入超时校验。 + if (ignoreMoneyCheck || ignoreRiskWarning) + { + var trialExpired = false; + if (trialDataId.HasValue) + { + // swapTradeView.js:按 quotaTrial.OptDate 校验,查不到记录也视为过期 + trialExpired = new QuotaMonitorService(CurUser).IsQuotaTrialExpired(trialDataId.Value, riskWarningConfirmExpireSeconds); + } + if (trialExpired) + { + ignoreMoneyCheck = false; + ignoreRiskWarning = false; + ignoreRiskRuleIdArr = Array.Empty(); + } + } var isSkipApproval = false; if (CurUser.交易管理_特批批量确认 && isSkipCheck) { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 2f6eb85f..7b832f3c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -49,7 +49,7 @@ function showSwapRate(timeRate, isMarginLeg) { $("#swapIntervalModal").modal("show"); } var layerIndex = -1; -var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds) { +var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDataId) { var pData = { tradeids: id }; if (!main.isEmpty(additionalProcessing)) { pData.additionalProcessing = additionalProcessing; @@ -57,6 +57,9 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds) { if (ignoreRiskRuleIds && ignoreRiskRuleIds.length > 0) { pData.ignoreRiskRuleIds = ignoreRiskRuleIds.join(','); } + if (trialDataId) { + pData.trialDataId = trialDataId; + } main.post("/trade/tradeConfirm", pData).done(function (data) { if (!data.success) { main.message(data.msg); @@ -103,6 +106,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds) { var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; // 新风控需审批时由后端返回本次命中的审批规则 id,前端二次确认时原样透传。 var currentIgnoreRiskRuleIds = data.obj.ignoreRiskRuleIds || []; + var currentTrialDataId = data.obj.TrialDataId; var saveQuotaTrial = function (obj) { var saveSuccess = true; if (obj.Data.Remark && obj.Data.Remark.length > 0) { @@ -150,7 +154,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds) { obj.Data.Remark = (obj.Data.Remark || "").trim(); } layer.close(layerIndex); - confirmFunc(id, additionalProcessingType, currentIgnoreRiskRuleIds); + confirmFunc(id, additionalProcessingType, currentIgnoreRiskRuleIds, currentTrialDataId); }, cancel: function () { var iframeWindow = window["layui-layer-iframe" + this.index]; From 4e6d887b684e60625a1ea6c943abb65b9cadca52 Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 9 Jul 2026 16:18:28 +0800 Subject: [PATCH 14/20] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskEngine/RiskEngineService.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index fea278d0..a6949a2a 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -497,7 +497,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000001, // RuleName = "挂钩标的集中度校验(本地)", // RuleText = "取值字段:分子取 QuotaMonitorService 已注入 DataMap[same_underlying_total_notional],该值按存续交易口径汇总同一标的 StockEqvNotional;分母取 DataMap[underlying_manager].IssueSize,债券标的场景下该值由 underlying_manager.ExJson 反序列化回填,含义按当前业务测试口径使用发行量(亿)。为什么这么取:当前上下文已经稳定注入了这两个值,且与集中度规则最接近正式口径。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量需先乘 100000000 还原为元,结果大于 30% 时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"same_underlying_total_notional\") && DataMap[\"same_underlying_total_notional\"] != null && DataMap.ContainsKey(\"underlying_manager\") && DataMap[\"underlying_manager\"] != null && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.HasValue && ((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.Value > 0 && Convert.ToDecimal(DataMap[\"same_underlying_total_notional\"]) / (((YLErp.DBModels.underlying_manager)DataMap[\"underlying_manager\"]).IssueSize.Value * 100000000m) * 100m > 30m", + // RuleExpr = "DataMap.ContainsKey("same_underlying_total_notional") && DataMap["same_underlying_total_notional"] != null && DataMap.ContainsKey("underlying_manager") && DataMap["underlying_manager"] != null && ((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.HasValue && ((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.Value > 0 && Convert.ToDecimal(DataMap["same_underlying_total_notional"]) / (((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.Value * 100000000m) * 100m > 30m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -513,7 +513,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000004, // RuleName = "保证金支付比例超阈值(本地)", // RuleText = "取值字段:直接取 DataMap[trade].MarginRate,对应 trade 表保证金率字段。为什么这么取:该字段已经在当前上下文稳定注入,且 seed 规则 4 的判断核心也是保证金比例。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate > 0.5", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).MarginRate > 0.5", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -529,7 +529,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000005, // RuleName = "保证金利率偏离(本地)", // RuleText = "取值字段:正式口径应取收益互换扩展数据中的保证金利率字段,但当前 RiskContext 未注入 trade_swap,因此本地测试先取 DataMap[trade].MarginRate 近似代替。为什么这么取:当前上下文只有 trade 可直接取值,先保证规则链路可验证。计算逻辑:若近似保证金利率不在 2% 到 5% 区间内,即小于 0.02 或大于 0.05,则触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate < 0.02) || (((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate > 0.05))", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((((YLErp.DBModels.trade)DataMap["trade"]).MarginRate < 0.02) || (((YLErp.DBModels.trade)DataMap["trade"]).MarginRate > 0.05))", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -545,7 +545,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000006, // RuleName = "保证金收取比例低于最低标准(本地)", // RuleText = "取值字段:正式口径应取配置项 MinMarginRate 或客户/品种最低保证金率,当前上下文未注入配置对象,因此本地测试仍取 DataMap[trade].MarginRate 做比较。为什么这么取:trade.MarginRate 是当前唯一稳定可得且能反映保证金比例的字段。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).MarginRate < 0.2", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).MarginRate < 0.2", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -561,7 +561,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000007, // RuleName = "起息日早于当前日期(本地)", // RuleText = "取值字段:取 DataMap[trade].StartDate,对应 trade 表开始日。为什么这么取:seed 规则 7 直接使用 StartDate 与当前日期比较,当前上下文也已注入 trade。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.Value.Date < DateTime.Today", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap["trade"]).StartDate.Value.Date < DateTime.Today", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -577,7 +577,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000008, // RuleName = "支付日为银行间交易日(本地)", // RuleText = "取值字段:取 DataMap[trade].SettlementDate,对应 trade 表结算日期。为什么这么取:当前代码中支付相关日期可直接从 trade 取得,且项目已有 QdpCalendarHelper.GetNonHolidayDefore 可用于交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).SettlementDate.Value.Date", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -593,7 +593,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000009, // RuleName = "到期日为银行间交易日(本地)", // RuleText = "取值字段:取 DataMap[trade].ExerciseDate,对应当前交易里更接近业务到期/行权日的字段。为什么这么取:TradeBase 中 MaturityDate 注释已提示容易与 ExerciseDate 混淆,当前测试按交易实际到期处理字段 ExerciseDate 落地,避免先取错口径。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -609,7 +609,7 @@ namespace YLErp.Modules.RiskEngine // Id = 1000010, // RuleName = "平仓日为银行间交易日(本地)", // RuleText = "取值字段:取 DataMap[trade].UnWindDate,对应 trade 表平仓日。为什么这么取:当前上下文已注入该字段,且 seed 规则中的平仓日判断在本地最接近该口径。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.Value.Date) != ((YLErp.DBModels.trade)DataMap[\"trade\"]).UnWindDate.Value.Date", + // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, From 66381e5dc43f86be6f9057907e3fa7a74d31f4fc Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 9 Jul 2026 17:41:10 +0800 Subject: [PATCH 15/20] =?UTF-8?q?feat:=E5=9C=A8=E7=B3=BB=E7=BB=9F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD=E5=86=85=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A3=8E=E6=8E=A7=E5=BC=95=E6=93=8E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Controllers/RiskRuleController.cs | 24 ---------------------- YLErpWeb/Controllers/SystemController.cs | 3 +++ 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index c6f238a3..bbb5a6fb 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -568,30 +568,6 @@ namespace YLErp.Web.Controllers #endregion - #region Cache Management - - [HttpGet("riskCache/refresh")] - [MyAuthorize("风险控制-异常交易监控")] - public JsonResult RefreshRiskEngineCache() - { - try - { - RiskEngineService.GetInstance().RefreshCache(); - return Json(new { success = true, message = "风控缓存刷新成功" }); - } - catch (ServiceException ex) - { - return Json(new { success = false, message = ex.Message }); - } - catch (Exception ex) - { - _logger.Error(ex, "刷新风控缓存"); - return Json(new { success = false, message = "系统异常,请联系管理员" }); - } - } - - #endregion - #region Trade Types [HttpGet("trade-types")] diff --git a/YLErpWeb/Controllers/SystemController.cs b/YLErpWeb/Controllers/SystemController.cs index 5b7d0681..1ee0bd83 100644 --- a/YLErpWeb/Controllers/SystemController.cs +++ b/YLErpWeb/Controllers/SystemController.cs @@ -15,6 +15,7 @@ using YLErp.BLL; using YLErp.DBModels; using YLErp.DBModels.Consts; using YLErp.Modules.DictionaryModule; +using YLErp.Modules.RiskEngine; using YLErp.Modules.SystemModule; using YLErp.Web.Models; @@ -1356,6 +1357,8 @@ namespace YLErp.Web.Controllers Providers.MemoryCacheProvider.Default.FlushAll(); Server.CacheProvider.FlushAll(); Modules.DataCacheModule.DataCacheManager.ResetDataCache(); + // 刷新风控引擎缓存 + RiskEngineService.GetInstance().RefreshCache(); return Json(1); } From c1ded96985dae45221045022f5e3538ed3245e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Thu, 9 Jul 2026 17:52:14 +0800 Subject: [PATCH 16/20] =?UTF-8?q?=E5=8F=98=E9=87=8F=E7=BA=A7=E8=81=94?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=A7=84=E5=88=99=E4=BC=98=E5=8C=96&?= =?UTF-8?q?=E5=A2=9E=E8=A1=A5=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/glms_risk_rule.cs | 12 ++ .../DBModels/glms_risk_rule_application.cs | 16 ++ .../DBModels/glms_risk_rule_audit_log.cs | 13 ++ .../YLErp.Core/DBModels/glms_risk_variable.cs | 15 ++ .../RiskEngine/Dto/BatchOperationResult.cs | 7 + .../Dto/CreateRiskApplicationReq.cs | 11 + .../RiskEngine/Dto/CreateRiskRuleReq.cs | 7 + .../RiskEngine/Dto/CreateRiskVariableReq.cs | 11 + .../RiskEngine/Dto/QueryRiskApplicationReq.cs | 11 + .../RiskEngine/Dto/QueryRiskAuditLogReq.cs | 11 + .../RiskEngine/Dto/QueryRiskRuleReq.cs | 6 + .../RiskEngine/Dto/QueryRiskVariableReq.cs | 6 + .../RiskEngine/Dto/RiskApplicationDetail.cs | 18 ++ .../RiskEngine/Dto/RiskApplicationListItem.cs | 18 ++ .../RiskEngine/Dto/RiskAuditLogDetail.cs | 14 ++ .../RiskEngine/Dto/RiskAuditLogListItem.cs | 12 ++ .../Dto/RiskRuleApplicationSummary.cs | 7 + .../Modules/RiskEngine/Dto/RiskRuleDetail.cs | 15 ++ .../RiskEngine/Dto/RiskRuleListItem.cs | 15 ++ .../RiskEngine/Dto/RiskRuleSimpleItem.cs | 6 + .../RiskEngine/Dto/RiskRuleVersionItem.cs | 8 + .../RiskEngine/Dto/RiskVariableDetail.cs | 18 ++ .../RiskEngine/Dto/RiskVariableListItem.cs | 13 ++ .../RiskEngine/Dto/RiskVariableSimpleItem.cs | 9 + .../Modules/RiskEngine/Dto/RuleCondition.cs | 11 +- .../Dto/UpdateRiskApplicationReq.cs | 12 ++ .../RiskEngine/Dto/UpdateRiskRuleReq.cs | 8 + .../RiskEngine/Dto/UpdateRiskVariableReq.cs | 12 ++ .../Modules/RiskEngine/RiskRuleService.cs | 194 +++++++++++++++++- YLErpWeb/Controllers/RiskRuleController.cs | 32 +++ 30 files changed, 545 insertions(+), 3 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs index 1581b02f..ad756d55 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs @@ -2,17 +2,29 @@ using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels { + /// + /// 风控规则定义表 + /// [Table("glms_risk_rule")] public class glms_risk_rule : DBModelWithOperator { + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } + /// 结构化条件 JSON(变量+操作符+阈值列表) public string ConditionJson { get; set; } + /// 规则执行表达式(C# 布尔表达式,引擎用 Roslyn 编译执行) public string RuleExpr { get; set; } + /// 规则状态 public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + /// 版本号(乐观锁) public int Version { get; set; } = 1; + /// 最后更新人工号 public int? UpdateOptId { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime? UpdateDate { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs index a1d577ac..f6cefd70 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs @@ -2,21 +2,37 @@ using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels { + /// + /// 风控规则应用配置表 + /// [Table("glms_risk_rule_application")] public class glms_risk_rule_application : DBModelWithOperator { + /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用配置状态 public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + /// 风控策略(预警/阻断) public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表(JSON 数组) public string TriggerPoints { get; set; } + /// 适用范围-资产账簿 ID 列表(JSON 数组) public string ScopeAssetBookIds { get; set; } + /// 适用范围-客户 ID 列表(JSON 数组) public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表(JSON 数组) public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表(JSON 数组) public string ScopeTradeTypes { get; set; } + /// 是否全局生效(0:否 1:是) public int ScopeIsGlobal { get; set; } + /// 版本号(乐观锁) public int Version { get; set; } = 1; + /// 最后更新人工号 public int? UpdateOptId { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime? UpdateDate { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs index fca43e2b..0ffdd3fe 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs @@ -2,18 +2,31 @@ using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels { + /// + /// 风控操作审计日志表 + /// [Table("glms_risk_rule_audit_log")] public class glms_risk_rule_audit_log : DBModelBase { + /// 操作类型(创建/更新/删除/启用/禁用) public string OperationType { get; set; } + /// 操作目标类型(Rule/Variable/Application) public string TargetType { get; set; } + /// 操作目标 ID public long TargetId { get; set; } + /// 操作目标名称 public string TargetName { get; set; } + /// 操作详情描述 public string OperationDetail { get; set; } + /// 操作结果(成功/失败) public string Result { get; set; } + /// 操作前数据快照(JSON) public string SnapshotData { get; set; } + /// 操作人工号 public long OptId { get; set; } + /// 操作人姓名 public string OptName { get; set; } + /// 操作时间 public DateTime OptDate { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs index 099312ae..2a856819 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs @@ -2,20 +2,35 @@ using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels { + /// + /// 风控变量池定义表 + /// [Table("glms_risk_variable")] public class glms_risk_variable : DBModelWithOperator { + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 值域描述 public string ValueDomain { get; set; } + /// 变量说明 public string Description { get; set; } + /// 取值表达式(C# 表达式,用于计算变量值) public string VariableExpr { get; set; } + /// 版本号(乐观锁) public int Version { get; set; } = 1; + /// 排序序号 public int SortOrder { get; set; } + /// 最后更新人工号 public int? UpdateOptId { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime? UpdateDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs index 0aed2a39..7d9dccb1 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -1,10 +1,17 @@ namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 批量操作结果 + /// public class BatchOperationResult { + /// 是否全部成功 public bool Success { get; set; } + /// 总数量 public int TotalCount { get; set; } + /// 成功数量 public int SuccessCount { get; set; } + /// 错误信息 public string ErrorMessage { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs index 65e939c4..71914328 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs @@ -2,15 +2,26 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 创建应用配置请求 + /// public class CreateRiskApplicationReq { + /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表(JSON 数组) public string TriggerPoints { get; set; } + /// 适用范围-资产账簿 ID 列表 public string ScopeAssetBookIds { get; set; } + /// 适用范围-客户 ID 列表 public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表 public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表 public string ScopeTradeTypes { get; set; } + /// 是否全局生效 public int ScopeIsGlobal { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs index a6d69dda..df27186d 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs @@ -1,10 +1,17 @@ namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 创建风控规则请求 + /// public class CreateRiskRuleReq { + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } + /// 结构化条件 JSON public string ConditionJson { get; set; } + /// 规则执行表达式 public string RuleExpr { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs index 707db27b..e8bff83a 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs @@ -2,15 +2,26 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 创建变量请求 + /// public class CreateRiskVariableReq { + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 值域描述 public string ValueDomain { get; set; } + /// 变量说明 public string Description { get; set; } + /// 取值表达式 public string VariableExpr { get; set; } + /// 排序序号 public int SortOrder { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs index eec45ca7..0a501867 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs @@ -3,15 +3,26 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 查询应用配置请求 + /// public class QueryRiskApplicationReq : BaseSearchReq { + /// 规则名称 public string RuleName { get; set; } + /// 应用配置状态 public RiskRuleStatus? Status { get; set; } + /// 风控策略 public RiskControlStrategy? Strategy { get; set; } + /// 触发点 public string TriggerPoint { get; set; } + /// 适用范围-客户 ID 列表 public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表 public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表 public string ScopeTradeTypes { get; set; } + /// 适用范围-资产账簿 ID 列表 public string ScopeAssetBookIds { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs index 6e77c983..d1f9d242 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs @@ -3,15 +3,26 @@ using BaseOUDAL; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 查询审计日志请求 + /// public class QueryRiskAuditLogReq : BaseSearchReq { + /// 操作类型 public string OperationType { get; set; } + /// 操作类型列表(多选,逗号分隔) public string OperationTypes { get; set; } + /// 操作目标类型 public string TargetType { get; set; } + /// 操作目标名称 public string TargetName { get; set; } + /// 操作人姓名 public string OptName { get; set; } + /// 开始日期 public DateTime? StartDate { get; set; } + /// 结束日期 public DateTime? EndDate { get; set; } + /// 搜索关键词 public string Keyword { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs index df7e1300..8339b900 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs @@ -3,10 +3,16 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 查询风控规则请求 + /// public class QueryRiskRuleReq : BaseSearchReq { + /// 搜索关键词 public string Keyword { get; set; } + /// 规则状态 public RiskRuleStatus? Status { get; set; } + /// 关联变量 ID public long? VariableId { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs index 9b8f39bd..ed4b9901 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs @@ -3,10 +3,16 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 查询变量请求 + /// public class QueryRiskVariableReq : BaseSearchReq { + /// 变量分类 public RiskVariableCategory? Category { get; set; } + /// 数据类型 public RiskVariableDataType? DataType { get; set; } + /// 变量名称 public string VariableName { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs index cb76b20c..648a08b8 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs @@ -3,22 +3,40 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 应用配置详情响应 + /// public class RiskApplicationDetail { + /// 应用配置 ID public long Id { get; set; } + /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用配置状态 public RiskRuleStatus Status { get; set; } + /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表(JSON 数组) public string TriggerPoints { get; set; } + /// 适用范围-资产账簿 ID 列表 public string ScopeAssetBookIds { get; set; } + /// 适用范围-客户 ID 列表 public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表 public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表 public string ScopeTradeTypes { get; set; } + /// 是否全局生效 public int ScopeIsGlobal { get; set; } + /// 版本号 public int Version { get; set; } + /// 创建人姓名 public string OptName { get; set; } + /// 创建时间 public DateTime OptDate { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime UpdateDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs index 466b1d94..fec34ea9 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs @@ -3,22 +3,40 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 应用配置列表项 + /// public class RiskApplicationListItem { + /// 应用配置 ID public long Id { get; set; } + /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用配置状态 public int Status { get; set; } + /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表(JSON 数组) public string TriggerPoints { get; set; } + /// 适用范围-资产账簿 ID 列表 public string ScopeAssetBookIds { get; set; } + /// 适用范围-客户 ID 列表 public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表 public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表 public string ScopeTradeTypes { get; set; } + /// 是否全局生效 public int ScopeIsGlobal { get; set; } + /// 版本号 public int Version { get; set; } + /// 创建人姓名 public string OptName { get; set; } + /// 创建时间 public DateTime OptDate { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime UpdateDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs index a4f52acf..e9418ec4 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs @@ -2,18 +2,32 @@ using System; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 审计日志详情响应 + /// public class RiskAuditLogDetail { + /// 日志 ID public long Id { get; set; } + /// 操作类型 public string OperationType { get; set; } + /// 操作目标类型 public string TargetType { get; set; } + /// 操作目标 ID public long TargetId { get; set; } + /// 操作目标名称 public string TargetName { get; set; } + /// 操作详情描述 public string OperationDetail { get; set; } + /// 操作结果 public string Result { get; set; } + /// 操作前数据快照(JSON) public string SnapshotData { get; set; } + /// 操作人工号 public long OptId { get; set; } + /// 操作人姓名 public string OptName { get; set; } + /// 操作时间 public DateTime OptDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs index 487f5f3f..3e2288ab 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs @@ -2,16 +2,28 @@ using System; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 审计日志列表项 + /// public class RiskAuditLogListItem { + /// 日志 ID public long Id { get; set; } + /// 操作类型 public string OperationType { get; set; } + /// 操作目标类型 public string TargetType { get; set; } + /// 操作目标 ID public long TargetId { get; set; } + /// 操作目标名称 public string TargetName { get; set; } + /// 操作详情描述 public string OperationDetail { get; set; } + /// 操作结果 public string Result { get; set; } + /// 操作人姓名 public string OptName { get; set; } + /// 操作时间 public DateTime OptDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs index 23c6e0e2..da6b37e4 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs @@ -2,11 +2,18 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 规则关联应用摘要 + /// public class RiskRuleApplicationSummary { + /// 应用配置 ID public long Id { get; set; } + /// 应用配置状态 public RiskRuleStatus Status { get; set; } + /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表 public string TriggerPoints { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs index 6abe752d..09364933 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs @@ -4,19 +4,34 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 风控规则详情响应 + /// public class RiskRuleDetail { + /// 规则 ID public long Id { get; set; } + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } + /// 结构化条件 JSON public string ConditionJson { get; set; } + /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则状态 public RiskRuleStatus Status { get; set; } + /// 版本号 public int Version { get; set; } + /// 创建人姓名 public string OptName { get; set; } + /// 创建时间 public DateTime OptDate { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime UpdateDate { get; set; } + /// 关联应用列表 public List Applications { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs index 988f55c4..32a462e2 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs @@ -3,19 +3,34 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 风控规则列表项 + /// public class RiskRuleListItem { + /// 规则 ID public long Id { get; set; } + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } + /// 结构化条件 JSON public string ConditionJson { get; set; } + /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则状态 public RiskRuleStatus Status { get; set; } + /// 版本号 public int Version { get; set; } + /// 创建人姓名 public string OptName { get; set; } + /// 创建时间 public DateTime OptDate { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime UpdateDate { get; set; } + /// 关联应用数量 public int ApplicationCount { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs index aea6a343..d2d98568 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs @@ -1,9 +1,15 @@ namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 风控规则简要信息 + /// public class RiskRuleSimpleItem { + /// 规则 ID public long Id { get; set; } + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs index b0842d6e..0e15cd14 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs @@ -2,12 +2,20 @@ using System; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 风控规则版本历史项 + /// public class RiskRuleVersionItem { + /// 版本号 public int Version { get; set; } + /// 操作类型 public string OperationType { get; set; } + /// 操作详情 public string OperationDetail { get; set; } + /// 操作人姓名 public string OptName { get; set; } + /// 操作时间 public DateTime OptDate { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs index bbadd4ce..c3dee5a8 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs @@ -3,22 +3,40 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 变量详情响应 + /// public class RiskVariableDetail { + /// 变量 ID public long Id { get; set; } + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 值域描述 public string ValueDomain { get; set; } + /// 变量说明 public string Description { get; set; } + /// 取值表达式 public string VariableExpr { get; set; } + /// 版本号 public int Version { get; set; } + /// 排序序号 public int SortOrder { get; set; } + /// 创建人姓名 public string OptName { get; set; } + /// 创建时间 public DateTime OptDate { get; set; } + /// 最后更新人姓名 public string UpdateOptName { get; set; } + /// 最后更新时间 public DateTime UpdateDate { get; set; } + /// 被引用次数 public int ReferenceCount { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs index c4ddf647..538cdcb9 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs @@ -2,17 +2,30 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 变量列表项 + /// public class RiskVariableListItem { + /// 变量 ID public long Id { get; set; } + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 值域描述 public string ValueDomain { get; set; } + /// 变量说明 public string Description { get; set; } + /// 取值表达式 public string VariableExpr { get; set; } + /// 版本号 public int Version { get; set; } + /// 排序序号 public int SortOrder { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs index 8585564d..f884a03e 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs @@ -2,13 +2,22 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 变量简要信息 + /// public class RiskVariableSimpleItem { + /// 变量 ID public long Id { get; set; } + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 取值表达式 public string VariableExpr { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs index d1e459ff..071be483 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs @@ -1,14 +1,23 @@ namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 规则条件项 + /// public class RuleCondition { + /// 条件变量 ID public long VariableId { get; set; } + /// 操作符(如 Equal、GreaterThan、Between 等) public string Operator { get; set; } + /// 阈值类型(FixedValue / VariableRef) public string ThresholdType { get; set; } + /// 阈值(固定值或变量引用) public object Value { get; set; } + /// 阈值变量 ID(当 ThresholdType 为 VariableRef 时) public long? ThresholdVariableId { get; set; } - // 介于/不介于 的边界包含控制,默认 true + /// 介于/不介于的下边界是否包含,默认 true public bool IncludeLowerBound { get; set; } = true; + /// 介于/不介于的上边界是否包含,默认 true public bool IncludeUpperBound { get; set; } = true; } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs index aa290529..f152f276 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs @@ -2,16 +2,28 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 更新应用配置请求 + /// public class UpdateRiskApplicationReq { + /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } + /// 触发点列表(JSON 数组) public string TriggerPoints { get; set; } + /// 适用范围-资产账簿 ID 列表 public string ScopeAssetBookIds { get; set; } + /// 适用范围-客户 ID 列表 public string ScopeClientIds { get; set; } + /// 适用范围-标的类型列表 public string ScopeUnderlyingTypes { get; set; } + /// 适用范围-交易类型列表 public string ScopeTradeTypes { get; set; } + /// 是否全局生效 public int ScopeIsGlobal { get; set; } + /// 版本号(乐观锁) public int Version { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs index 85521bac..9fa13d3c 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs @@ -1,11 +1,19 @@ namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 更新风控规则请求 + /// public class UpdateRiskRuleReq { + /// 规则名称 public string RuleName { get; set; } + /// 规则描述文本 public string RuleText { get; set; } + /// 结构化条件 JSON public string ConditionJson { get; set; } + /// 规则执行表达式 public string RuleExpr { get; set; } + /// 版本号(乐观锁) public int Version { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs index 2990b7c1..c9ca1b3a 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs @@ -2,16 +2,28 @@ using YLErp.DBModels; namespace YLErp.Modules.RiskEngine.Dto { + /// + /// 更新变量请求 + /// public class UpdateRiskVariableReq { + /// 变量名称 public string VariableName { get; set; } + /// 变量分类 public RiskVariableCategory Category { get; set; } + /// 数据类型 public RiskVariableDataType DataType { get; set; } + /// 单位 public string Unit { get; set; } + /// 值域描述 public string ValueDomain { get; set; } + /// 变量说明 public string Description { get; set; } + /// 取值表达式 public string VariableExpr { get; set; } + /// 排序序号 public int SortOrder { get; set; } + /// 版本号(乐观锁) public int Version { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 08421a07..b2162888 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -221,6 +221,9 @@ namespace YLErp.Modules.RiskEngine #region Private Helpers + /// + /// 写入审计日志 + /// private void WriteAuditLog(string operationType, string targetType, long targetId, string targetName, string operationDetail, string result = "成功", string snapshotData = null) { @@ -240,6 +243,9 @@ namespace YLErp.Modules.RiskEngine DbContext.glms_risk_rule_audit_log.Add(log); } + /// + /// 获取变量表达式集合(5 分钟本地缓存) + /// private HashSet GetVariableExprSet() { if (_variableExprCache == null || @@ -254,11 +260,17 @@ namespace YLErp.Modules.RiskEngine return _variableExprCache; } + /// + /// 失效变量表达式缓存 + /// private void InvalidateVariableCache() { _variableExprCache = null; } + /// + /// 校验 ConditionJson(结构化模式:变量存在性、操作符、类型匹配) + /// private void ValidateConditionJson(string conditionJson) { List conditions; @@ -341,6 +353,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 校验 RuleExpr(自由文本模式:括号匹配) + /// private void ValidateRuleExpr(string ruleExpr) { if (ruleExpr.Length > 10000) @@ -358,6 +373,9 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("公式表达式括号不匹配:缺少右括号"); } + /// + /// 校验触发时点 + /// private void ValidateTriggerPoints(string triggerPoints) { if (string.IsNullOrWhiteSpace(triggerPoints)) @@ -375,6 +393,9 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("至少需要指定一个触发时点"); } + /// + /// 校验 Scope 适用范围字段 + /// private void ValidateScopeFields(CreateRiskApplicationReq req) { if (req.ScopeIsGlobal == 1) @@ -400,6 +421,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 校验 ID 列表格式 + /// private void ValidateIdList(string idList, string fieldName) { var parts = idList.Split(','); @@ -410,6 +434,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 尝试刷新引擎缓存(不抛异常) + /// private void TryRefreshCache() { try @@ -422,6 +449,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 获取数据类型中文名 + /// private string GetDataTypeName(RiskVariableDataType dataType) { return dataType switch @@ -433,6 +463,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 获取规则实体,不存在则抛异常 + /// private glms_risk_rule GetRuleOrThrow(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status != RiskRuleStatus.Deleted); @@ -441,6 +474,9 @@ namespace YLErp.Modules.RiskEngine return rule; } + /// + /// 获取应用配置实体,不存在则抛异常 + /// private glms_risk_rule_application GetApplicationOrThrow(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status != RiskRuleStatus.Deleted); @@ -449,6 +485,9 @@ namespace YLErp.Modules.RiskEngine return app; } + /// + /// 获取变量实体,不存在则抛异常 + /// private glms_risk_variable GetVariableOrThrow(long variableId) { var variable = DbContext.glms_risk_variable.FirstOrDefault(v => v.id == variableId); @@ -457,6 +496,9 @@ namespace YLErp.Modules.RiskEngine return variable; } + /// + /// 校验规则参数(名称 + ConditionJson + RuleExpr) + /// private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr) { if (string.IsNullOrWhiteSpace(ruleName)) @@ -475,6 +517,9 @@ namespace YLErp.Modules.RiskEngine ValidateRuleExpr(ruleExpr); } + /// + /// 解析逗号分隔的规则 ID 字符串为 List + /// private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) @@ -495,6 +540,9 @@ namespace YLErp.Modules.RiskEngine return ids; } + /// + /// 校验规则 ID 列表是否全部 Active + /// private void ValidateRuleIdsActive(string ruleIds) { var ids = ParseRuleIds(ruleIds); @@ -512,6 +560,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 根据规则 ID 字符串解析为规则名称字符串 + /// private string ResolveRuleNames(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) @@ -569,6 +620,9 @@ namespace YLErp.Modules.RiskEngine return predicate != null ? query.Where(predicate) : query; } + /// + /// 获取控制策略中文名 + /// private static string GetStrategyName(RiskControlStrategy strategy) { return strategy switch @@ -589,6 +643,9 @@ namespace YLErp.Modules.RiskEngine ["FUND_PAYMENT"] = "资金支付" }; + /// + /// 将触发时点映射为中文 + /// private static string MapTriggerPointsToChinese(string triggerPoints) { if (string.IsNullOrWhiteSpace(triggerPoints)) @@ -603,6 +660,9 @@ namespace YLErp.Modules.RiskEngine #region Rule Management + /// + /// 查询规则列表(分页,含 ApplicationCount 子查询) + /// public SearchListResult QueryRuleList(QueryRiskRuleReq req) { var query = DbContext.glms_risk_rule @@ -646,6 +706,9 @@ namespace YLErp.Modules.RiskEngine return result.ToSearchList(req); } + /// + /// 获取规则详情(含关联应用摘要) + /// public RiskRuleDetail GetRuleDetail(long ruleId) { var rule = GetRuleOrThrow(ruleId); @@ -679,6 +742,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 创建规则(ConditionJson 完整校验,事务包裹) + /// public RiskRuleDetail CreateRule(CreateRiskRuleReq req) { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); @@ -723,6 +789,9 @@ namespace YLErp.Modules.RiskEngine return GetRuleDetail(entity.id); } + /// + /// 更新规则(乐观锁校验,Version+1) + /// public RiskRuleDetail UpdateRule(long ruleId, UpdateRiskRuleReq req) { var rule = GetRuleOrThrow(ruleId); @@ -749,6 +818,9 @@ namespace YLErp.Modules.RiskEngine return GetRuleDetail(ruleId); } + /// + /// 删除规则(软删除,有 Active 应用引用时拒绝) + /// public void DeleteRule(long ruleId) { var rule = GetRuleOrThrow(ruleId); @@ -771,6 +843,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 启用规则(仅 Disabled → Active) + /// public void EnableRule(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Disabled); @@ -788,6 +863,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 停用规则(仅 Active → Disabled) + /// public void DisableRule(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Active); @@ -805,6 +883,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 获取规则版本历史(从审计日志 SnapshotData 提取) + /// public List GetRuleVersions(long ruleId) { GetRuleOrThrow(ruleId); @@ -842,6 +923,9 @@ namespace YLErp.Modules.RiskEngine return result; } + /// + /// 批量删除规则(逐条保护检查) + /// public BatchOperationResult BatchDeleteRules(List ruleIds) { var rules = DbContext.glms_risk_rule @@ -882,6 +966,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 获取规则关联的应用配置列表 + /// public List GetRuleApplications(long ruleId) { GetRuleOrThrow(ruleId); @@ -918,6 +1005,9 @@ namespace YLErp.Modules.RiskEngine return result; } + /// + /// 获取所有启用规则列表(轻量字段,供下拉选择) + /// public List GetAllRuleList() { return DbContext.glms_risk_rule @@ -936,6 +1026,9 @@ namespace YLErp.Modules.RiskEngine #region Application Management + /// + /// 查询应用配置列表(多条件筛选,内存关联规则名称) + /// public SearchListResult QueryApplicationList(QueryRiskApplicationReq req) { var query = DbContext.glms_risk_rule_application @@ -1000,6 +1093,9 @@ namespace YLErp.Modules.RiskEngine return pagedResult; } + /// + /// 获取应用配置详情(含关联规则信息) + /// public RiskApplicationDetail GetApplicationDetail(long applicationId) { var app = DbContext.glms_risk_rule_application @@ -1027,6 +1123,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 创建应用配置(关联规则 Active 校验,事务包裹) + /// public RiskApplicationDetail CreateApplication(CreateRiskApplicationReq req) { ValidateRuleIdsActive(req.RuleIds); @@ -1078,6 +1177,9 @@ namespace YLErp.Modules.RiskEngine return GetApplicationDetail(entity.id); } + /// + /// 更新应用配置(乐观锁校验) + /// public RiskApplicationDetail UpdateApplication(long applicationId, UpdateRiskApplicationReq req) { var app = GetApplicationOrThrow(applicationId); @@ -1122,6 +1224,9 @@ namespace YLErp.Modules.RiskEngine return GetApplicationDetail(applicationId); } + /// + /// 删除应用配置(软删除) + /// public void DeleteApplication(long applicationId) { var app = GetApplicationOrThrow(applicationId); @@ -1138,6 +1243,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 启用应用配置(校验关联规则状态) + /// public void EnableApplication(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status == RiskRuleStatus.Disabled); @@ -1157,6 +1265,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 停用应用配置 + /// public void DisableApplication(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status == RiskRuleStatus.Active); @@ -1174,6 +1285,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 批量启用应用配置(逐条校验关联规则) + /// public BatchOperationResult BatchEnableApplications(List applicationIds) { var apps = DbContext.glms_risk_rule_application @@ -1209,6 +1323,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 批量停用应用配置 + /// public BatchOperationResult BatchDisableApplications(List applicationIds) { var apps = DbContext.glms_risk_rule_application @@ -1243,6 +1360,9 @@ namespace YLErp.Modules.RiskEngine #region Variable Management + /// + /// 查询变量列表(全量返回,Category/Keyword 筛选) + /// public SearchListResult QueryVariableList(QueryRiskVariableReq req) { var query = DbContext.glms_risk_variable.AsQueryable(); @@ -1280,6 +1400,9 @@ namespace YLErp.Modules.RiskEngine return result.ToSearchList(req); } + /// + /// 获取变量详情(含 ReferenceCount 引用统计) + /// public RiskVariableDetail GetVariableDetail(long variableId) { var variable = GetVariableOrThrow(variableId); @@ -1310,6 +1433,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 创建变量(VariableExpr 长度校验 ≤10000,事务包裹) + /// public RiskVariableDetail CreateVariable(CreateRiskVariableReq req) { if (string.IsNullOrWhiteSpace(req.VariableName)) @@ -1363,6 +1489,9 @@ namespace YLErp.Modules.RiskEngine return GetVariableDetail(entity.id); } + /// + /// 更新变量(乐观锁,DataType 变更保护,级联更新引用规则 RuleExpr) + /// public RiskVariableDetail UpdateVariable(long variableId, UpdateRiskVariableReq req) { var variable = GetVariableOrThrow(variableId); @@ -1386,6 +1515,8 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法修改数据类型"); } + var oldVariableExpr = variable.VariableExpr; + variable.VariableName = req.VariableName; variable.Category = req.Category; variable.DataType = req.DataType; @@ -1399,8 +1530,52 @@ namespace YLErp.Modules.RiskEngine variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; - WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); - DbContext.SaveChanges(); + var variableExprChanged = oldVariableExpr != req.VariableExpr; + + if (variableExprChanged) + { + using (var transaction = DbContext.Database.BeginTransaction()) + { + try + { + var affectedRules = DbContext.glms_risk_rule + .Where(r => r.RuleExpr != null && r.RuleExpr.Contains(oldVariableExpr)) + .ToList(); + + foreach (var rule in affectedRules) + { + rule.RuleExpr = rule.RuleExpr.Replace(oldVariableExpr, req.VariableExpr); + rule.Version = rule.Version + 1; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + } + + DbContext.SaveChanges(); + + WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); + + foreach (var rule in affectedRules) + { + WriteAuditLog("VAR_UPDATE_CASCADE", "RULE", rule.id, rule.RuleName, + $"变量 [{variable.VariableName}] VariableExpr 变更,级联更新规则 RuleExpr"); + } + + DbContext.SaveChanges(); + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + else + { + WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); + DbContext.SaveChanges(); + } InvalidateVariableCache(); TryRefreshCache(); @@ -1408,6 +1583,9 @@ namespace YLErp.Modules.RiskEngine return GetVariableDetail(variableId); } + /// + /// 删除变量(有 Active 引用时拒绝) + /// public void DeleteVariable(long variableId) { var variable = GetVariableOrThrow(variableId); @@ -1429,6 +1607,9 @@ namespace YLErp.Modules.RiskEngine TryRefreshCache(); } + /// + /// 获取所有变量列表(供下拉选择) + /// public List GetAllVariableList() { var result = DbContext.glms_risk_variable @@ -1452,6 +1633,9 @@ namespace YLErp.Modules.RiskEngine #region Audit Log + /// + /// 查询审计日志列表(分页) + /// public SearchListResult QueryAuditLogs(QueryRiskAuditLogReq req) { var query = DbContext.glms_risk_rule_audit_log.AsQueryable(); @@ -1516,11 +1700,17 @@ namespace YLErp.Modules.RiskEngine return result.ToSearchList(req); } + /// + /// 导出审计日志为 Excel + /// public byte[] ExportAuditLogs(QueryRiskAuditLogReq req) { throw new ServiceException("导出功能暂未实现"); } + /// + /// 获取审计日志详情 + /// public RiskAuditLogDetail GetAuditLogDetail(long logId) { var log = DbContext.glms_risk_rule_audit_log.FirstOrDefault(l => l.id == logId); diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index c6f238a3..d45ac656 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -5,6 +5,7 @@ using YLErp.Modules.RiskEngine.Dto; namespace YLErp.Web.Controllers { + /// 风控引擎管理控制器,提供规则、应用配置、变量池和审计日志的 CRUD 接口 [Route("risk-engine")] public class RiskRuleController : BaseController { @@ -14,6 +15,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules")] [MyAuthorize("风险控制-风控规则查看")] + /// 查询风控规则列表(分页) public JsonResult QueryRiskRules(QueryRiskRuleReq req) { try @@ -35,6 +37,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules/{id}")] [MyAuthorize("风险控制-风控规则查看")] + /// 获取风控规则详情 public JsonResult GetRiskRule(long id) { try @@ -56,6 +59,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules/{id}/versions")] [MyAuthorize("风险控制-风控规则查看")] + /// 获取风控规则版本历史 public JsonResult GetRiskRuleVersions(long id) { try @@ -77,6 +81,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-rules")] [MyAuthorize("风险控制-风控规则新增")] + /// 创建风控规则 public JsonResult CreateRiskRule([FromBody] CreateRiskRuleReq req) { try @@ -98,6 +103,7 @@ namespace YLErp.Web.Controllers [HttpPut("risk-rules/{id}")] [MyAuthorize("风险控制-风控规则编辑")] + /// 更新风控规则 public JsonResult UpdateRiskRule(long id, [FromBody] UpdateRiskRuleReq req) { try @@ -119,6 +125,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-rules/{id}")] [MyAuthorize("风险控制-风控规则删除")] + /// 删除风控规则(软删除) public JsonResult DeleteRiskRule(long id) { try @@ -140,6 +147,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-rules/batch")] [MyAuthorize("风险控制-风控规则删除")] + /// 批量删除风控规则 public JsonResult BatchDeleteRiskRules([FromBody] List ids) { try @@ -161,6 +169,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-rules/{id}/enable")] [MyAuthorize("风险控制-风控规则启停")] + /// 启用风控规则 public JsonResult EnableRiskRule(long id) { try @@ -182,6 +191,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-rules/{id}/disable")] [MyAuthorize("风险控制-风控规则启停")] + /// 停用风控规则 public JsonResult DisableRiskRule(long id) { try @@ -203,6 +213,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules/{id}/applications")] [MyAuthorize("风险控制-风控规则查看")] + /// 获取规则关联的应用配置列表 public JsonResult GetRuleApplications(long id) { try @@ -224,6 +235,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules/list")] [MyAuthorize("风险控制-风控规则查看")] + /// 获取所有启用规则列表(供下拉选择) public JsonResult GetAllRiskRules() { try @@ -249,6 +261,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-applications")] [MyAuthorize("风险控制-风控应用查看")] + /// 查询应用配置列表(分页) public JsonResult QueryRiskApplications(QueryRiskApplicationReq req) { try @@ -270,6 +283,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-applications/{id}")] [MyAuthorize("风险控制-风控应用查看")] + /// 获取应用配置详情 public JsonResult GetRiskApplication(long id) { try @@ -291,6 +305,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications")] [MyAuthorize("风险控制-风控应用新增")] + /// 创建应用配置 public JsonResult CreateRiskApplication([FromBody] CreateRiskApplicationReq req) { try @@ -312,6 +327,7 @@ namespace YLErp.Web.Controllers [HttpPut("risk-applications/{id}")] [MyAuthorize("风险控制-风控应用编辑")] + /// 更新应用配置 public JsonResult UpdateRiskApplication(long id, [FromBody] UpdateRiskApplicationReq req) { try @@ -333,6 +349,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-applications/{id}")] [MyAuthorize("风险控制-风控应用删除")] + /// 删除应用配置(软删除) public JsonResult DeleteRiskApplication(long id) { try @@ -354,6 +371,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/{id}/enable")] [MyAuthorize("风险控制-风控应用启停")] + /// 启用应用配置 public JsonResult EnableRiskApplication(long id) { try @@ -375,6 +393,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/{id}/disable")] [MyAuthorize("风险控制-风控应用启停")] + /// 停用应用配置 public JsonResult DisableRiskApplication(long id) { try @@ -396,6 +415,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/batch-enable")] [MyAuthorize("风险控制-风控应用启停")] + /// 批量启用应用配置 public JsonResult BatchEnableRiskApplications([FromBody] List ids) { try @@ -417,6 +437,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/batch-disable")] [MyAuthorize("风险控制-风控应用启停")] + /// 批量停用应用配置 public JsonResult BatchDisableRiskApplications([FromBody] List ids) { try @@ -442,6 +463,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-variables")] [MyAuthorize("风险控制-风控变量查看")] + /// 查询变量列表 public JsonResult QueryRiskVariables(QueryRiskVariableReq req) { try @@ -463,6 +485,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-variables/{id}")] [MyAuthorize("风险控制-风控变量查看")] + /// 获取变量详情 public JsonResult GetRiskVariable(long id) { try @@ -484,6 +507,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-variables/list")] [MyAuthorize("风险控制-风控变量查看")] + /// 获取所有变量列表(供下拉选择) public JsonResult GetAllRiskVariables() { try @@ -505,6 +529,7 @@ namespace YLErp.Web.Controllers [HttpPost("risk-variables")] [MyAuthorize("风险控制-风控变量新增")] + /// 创建变量 public JsonResult CreateRiskVariable([FromBody] CreateRiskVariableReq req) { try @@ -526,6 +551,7 @@ namespace YLErp.Web.Controllers [HttpPut("risk-variables/{id}")] [MyAuthorize("风险控制-风控变量编辑")] + /// 更新变量(含级联更新引用规则的 RuleExpr) public JsonResult UpdateRiskVariable(long id, [FromBody] UpdateRiskVariableReq req) { try @@ -547,6 +573,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-variables/{id}")] [MyAuthorize("风险控制-风控变量删除")] + /// 删除变量 public JsonResult DeleteRiskVariable(long id) { try @@ -572,6 +599,7 @@ namespace YLErp.Web.Controllers [HttpGet("riskCache/refresh")] [MyAuthorize("风险控制-异常交易监控")] + /// 手动刷新风控引擎缓存 public JsonResult RefreshRiskEngineCache() { try @@ -596,6 +624,7 @@ namespace YLErp.Web.Controllers [HttpGet("trade-types")] [MyAuthorize("风险控制-风控应用查看")] + /// 获取合约类型列表 public JsonResult GetTradeTypes() { try @@ -625,6 +654,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-audit-logs")] [MyAuthorize("风险控制-风控日志查看")] + /// 查询风控操作日志列表(分页) public JsonResult QueryRiskAuditLogs(QueryRiskAuditLogReq req) { try @@ -646,6 +676,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-audit-logs/{id}")] [MyAuthorize("风险控制-风控日志查看")] + /// 获取风控操作日志详情 public JsonResult GetRiskAuditLogDetail(long id) { try @@ -667,6 +698,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-audit-logs/export")] [MyAuthorize("风险控制-风控日志导出")] + /// 导出风控操作日志为 Excel public IActionResult ExportRiskAuditLogs(QueryRiskAuditLogReq req) { try From 18fdb9e9f19b8016974a7ee83e2d63ef8500b86f Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 9 Jul 2026 18:01:55 +0800 Subject: [PATCH 17/20] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E6=9D=A1rule=E5=A2=9E=E5=88=A0=E6=94=B9=E5=87=BD=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E5=8F=8Aapplication=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=87=BD=E6=95=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/RiskEngineService.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index a6949a2a..b169db60 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -205,6 +205,50 @@ namespace YLErp.Modules.RiskEngine Preload(); } + /// + /// 刷新单条规则缓存:重新加载规则与应用列表,只编译指定规则。 + /// + public RuleCompileResult RefreshOneRuleCache(long ruleId) + { + lock (_cacheLock) + { + _logger.Info($"[风控引擎] RefreshOneRuleCache 开始 - RuleId: {ruleId}"); + // 从数据库加载最新规则列表 + _cachedRules = LoadRulesFromDb(); + + var rule = _cachedRules.FirstOrDefault(r => r.Id == ruleId); + if (rule == null) + { + RuleCompiledCache.Remove(ruleId.ToString()); + _logger.Info($"[风控引擎] RefreshOneRuleCache 未找到规则,已移除缓存 - RuleId: {ruleId}"); + return RuleCompileResult.Fail("规则不存在或已删除"); + } + + if (rule.Status != RiskRuleStatus.Active) + { + RuleCompiledCache.Remove(ruleId.ToString()); + _logger.Info($"[风控引擎] RefreshOneRuleCache 规则非活跃,已移除缓存 - RuleId: {ruleId}, Status: {rule.Status}"); + return RuleCompileResult.Fail("规则不存在或非活跃状态"); + } + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + _logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}"); + return compileResult; + } + } + + /// + /// 仅刷新规则应用列表,不清空规则编译缓存。 + /// + public void RefreshApplication() + { + lock (_cacheLock) + { + _cachedApplications = LoadApplicationsFromDb(); + _logger.Info($"[风控引擎] RefreshApplication 完成 - 应用数: {_cachedApplications.Count}"); + } + } + /// /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 /// From b5f6b7f61a268da00e30b44512c80c5deca349f6 Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 10 Jul 2026 10:31:41 +0800 Subject: [PATCH 18/20] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0ruleid=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=E6=A0=A1=E9=AA=8C=EF=BC=8C=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81rule=E6=96=B0=E5=A2=9E=E3=80=82=E5=A2=9E=E5=8A=A0prelo?= =?UTF-8?q?ad=E6=88=90=E5=8A=9F=E7=BC=96=E8=AF=91rule=E7=9A=84=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/Compile/RuleCompiler.cs | 15 +++++++++------ YLErpDAL/Modules/RiskEngine/RiskEngineService.cs | 6 +++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 3ed2b753..e70e7648 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -59,12 +59,14 @@ namespace YLErp.Modules.RiskEngine /// /// 校验并编译规则表达式。 /// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。 + /// ruleId 可为空,用于规则未落库时的公式校验。 /// - public static RuleCompileResult ValidateAndCompileFormula(long ruleId, string formulaExp) + public static RuleCompileResult ValidateAndCompileFormula(long? ruleId, string formulaExp) { + var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库"; if (string.IsNullOrWhiteSpace(formulaExp)) { - _logger.Error($"规则表达式为空,无法编译 - RuleId: {ruleId}"); + _logger.Error($"规则表达式为空,无法编译 - RuleId: {ruleIdText}"); return RuleCompileResult.Fail("规则 RuleExpr 不能为空"); } @@ -80,7 +82,7 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - _logger.Error($"规则表达式校验异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{formulaExp}"); + _logger.Error($"规则表达式校验异常 - RuleId: {ruleIdText}, Error: {ex.Message}\n脚本代码:{formulaExp}"); return RuleCompileResult.Fail(ex.Message); } } @@ -124,9 +126,10 @@ namespace YLErp.Modules.RiskEngine /// /// 用 Roslyn 编译 C# 脚本代码为可执行委托 /// - private static Func CompileScript(long ruleId, string scriptCode, out string errorMessage) + private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) { errorMessage = null; + var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库"; // 配置编译选项:引用必要的程序集 var options = ScriptOptions.Default @@ -147,7 +150,7 @@ namespace YLErp.Modules.RiskEngine if (errors.Any()) { errorMessage = string.Join("; ", errors.Select(e => e.GetMessage())); - _logger.Error($"规则编译失败 - RuleId: {ruleId}, Error: {errorMessage}\n脚本代码:{scriptCode}"); + _logger.Error($"规则编译失败 - RuleId: {ruleIdText}, Error: {errorMessage}\n脚本代码:{scriptCode}"); return null; } @@ -165,7 +168,7 @@ namespace YLErp.Modules.RiskEngine catch (Exception ex) { // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 - _logger.Error($"规则执行异常 - RuleId: {ruleId}, Error: {ex.Message}\n脚本代码:{scriptCode}"); + _logger.Error($"规则执行异常 - RuleId: {ruleIdText}, Error: {ex.Message}\n脚本代码:{scriptCode}"); return false; } }; diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index b169db60..f48bcc78 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -175,7 +175,11 @@ namespace YLErp.Modules.RiskEngine } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) + if (compileResult.Success) + { + _logger.Info($"[风控引擎] 规则预编译成功 - RuleId: {rule.Id}"); + } + else { _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); } From abf2cf5985413fb972d4537a95dc70bacac6d16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Fri, 10 Jul 2026 18:23:39 +0800 Subject: [PATCH 19/20] =?UTF-8?q?=E8=A7=84=E5=88=99=E5=92=8C=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=A2=9E=E5=8A=A0description=E5=AD=97=E6=AE=B5&?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E5=92=8C=E8=A7=84=E5=88=99=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=85=AC=E5=BC=8F=E7=BC=96=E8=AF=91=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/glms_risk_rule.cs | 2 + .../DBModels/glms_risk_rule_application.cs | 2 + .../DbUpdate/Ver-5.6.0/prod.sql | 2 + .../Dto/CreateRiskApplicationReq.cs | 2 + .../RiskEngine/Dto/CreateRiskRuleReq.cs | 2 + .../RiskEngine/Dto/RiskApplicationDetail.cs | 2 + .../RiskEngine/Dto/RiskApplicationListItem.cs | 2 + .../Modules/RiskEngine/Dto/RiskRuleDetail.cs | 2 + .../RiskEngine/Dto/RiskRuleListItem.cs | 2 + .../Dto/UpdateRiskApplicationReq.cs | 2 + .../RiskEngine/Dto/UpdateRiskRuleReq.cs | 2 + .../Modules/RiskEngine/RiskRuleService.cs | 66 +++++++++++++------ 12 files changed, 69 insertions(+), 19 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs index ad756d55..8359f712 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs @@ -16,6 +16,8 @@ namespace YLErp.DBModels public string ConditionJson { get; set; } /// 规则执行表达式(C# 布尔表达式,引擎用 Roslyn 编译执行) public string RuleExpr { get; set; } + /// 规则说明 + public string Description { get; set; } /// 规则状态 public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; /// 版本号(乐观锁) diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs index f6cefd70..89da2889 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs @@ -10,6 +10,8 @@ namespace YLErp.DBModels { /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用说明 + public string Description { get; set; } /// 应用配置状态 public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; /// 风控策略(预警/阻断) diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql index d9642a21..448439e8 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql @@ -6,6 +6,7 @@ CREATE TABLE `glms_risk_rule` ( `RuleText` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '规则文本', `ConditionJson` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '条件JSON(结构化模式, RuleCondition数组)', `RuleExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '规则表达式(自由文本模式, 类C#表达式)', + `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', @@ -21,6 +22,7 @@ CREATE TABLE `glms_risk_rule` ( CREATE TABLE `glms_risk_rule_application` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', `RuleIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '关联规则Id列表(逗号分隔)', + `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', `ControlStrategy` tinyint NOT NULL COMMENT '控制策略: 1=Block, 2=Approval, 3=Warning', `TriggerPoints` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '触发时点(逗号分隔)', diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs index 71914328..b426ea1c 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs @@ -9,6 +9,8 @@ namespace YLErp.Modules.RiskEngine.Dto { /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用说明 + public string Description { get; set; } /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } /// 触发点列表(JSON 数组) diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs index df27186d..280c3e17 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs @@ -13,5 +13,7 @@ namespace YLErp.Modules.RiskEngine.Dto public string ConditionJson { get; set; } /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则说明 + public string Description { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs index 648a08b8..11d5cf7f 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs @@ -12,6 +12,8 @@ namespace YLErp.Modules.RiskEngine.Dto public long Id { get; set; } /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用说明 + public string Description { get; set; } /// 应用配置状态 public RiskRuleStatus Status { get; set; } /// 风控策略 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs index fec34ea9..f50283ed 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs @@ -12,6 +12,8 @@ namespace YLErp.Modules.RiskEngine.Dto public long Id { get; set; } /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用说明 + public string Description { get; set; } /// 应用配置状态 public int Status { get; set; } /// 风控策略 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs index 09364933..cdb2e9ec 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs @@ -19,6 +19,8 @@ namespace YLErp.Modules.RiskEngine.Dto public string ConditionJson { get; set; } /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则说明 + public string Description { get; set; } /// 规则状态 public RiskRuleStatus Status { get; set; } /// 版本号 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs index 32a462e2..b35999a2 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs @@ -18,6 +18,8 @@ namespace YLErp.Modules.RiskEngine.Dto public string ConditionJson { get; set; } /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则说明 + public string Description { get; set; } /// 规则状态 public RiskRuleStatus Status { get; set; } /// 版本号 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs index f152f276..877639d0 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs @@ -9,6 +9,8 @@ namespace YLErp.Modules.RiskEngine.Dto { /// 关联规则 ID 列表(JSON 数组) public string RuleIds { get; set; } + /// 应用说明 + public string Description { get; set; } /// 风控策略 public RiskControlStrategy ControlStrategy { get; set; } /// 触发点列表(JSON 数组) diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs index 9fa13d3c..0d19a313 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs @@ -13,6 +13,8 @@ namespace YLErp.Modules.RiskEngine.Dto public string ConditionJson { get; set; } /// 规则执行表达式 public string RuleExpr { get; set; } + /// 规则说明 + public string Description { get; set; } /// 版本号(乐观锁) public int Version { get; set; } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index b2162888..783a6bbc 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,10 +1,8 @@ using BaseOUDAL; using Newtonsoft.Json; -using Qdp.Foundation.Utilities; using System.Linq.Expressions; using YLErp.BLL; using YLErp.DBModels; -using YLErp.Model; using YLErp.Modules.RiskEngine.Dto; /* @@ -694,6 +692,7 @@ namespace YLErp.Modules.RiskEngine RuleText = r.RuleText, ConditionJson = r.ConditionJson, RuleExpr = r.RuleExpr, + Description = r.Description, Status = r.Status, Version = r.Version, OptName = r.OptName, @@ -732,6 +731,7 @@ namespace YLErp.Modules.RiskEngine RuleText = rule.RuleText, ConditionJson = rule.ConditionJson, RuleExpr = rule.RuleExpr, + Description = rule.Description, Status = rule.Status, Version = rule.Version, OptName = rule.OptName, @@ -749,12 +749,17 @@ namespace YLErp.Modules.RiskEngine { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); + var compileResult = RuleCompiler.ValidateAndCompileFormula(null, req.RuleExpr); + if (!compileResult.Success) + throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + var entity = new glms_risk_rule { RuleName = req.RuleName, RuleText = req.RuleText, ConditionJson = req.ConditionJson, RuleExpr = req.RuleExpr, + Description = req.Description, Status = RiskRuleStatus.Active, Version = 1 }; @@ -784,7 +789,7 @@ namespace YLErp.Modules.RiskEngine } } - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshOneRuleCache(entity.id); return GetRuleDetail(entity.id); } @@ -801,10 +806,15 @@ namespace YLErp.Modules.RiskEngine ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); + var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); + if (!compileResult.Success) + throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + rule.RuleName = req.RuleName; rule.RuleText = req.RuleText; rule.ConditionJson = req.ConditionJson; rule.RuleExpr = req.RuleExpr; + rule.Description = req.Description; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; @@ -813,7 +823,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("RULE_UPDATE", "RULE", ruleId, req.RuleName, "修改规则"); DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); return GetRuleDetail(ruleId); } @@ -1074,6 +1084,7 @@ namespace YLErp.Modules.RiskEngine { Id = a.id, RuleIds = a.RuleIds, + Description = a.Description, Status = (int)a.Status, ControlStrategy = a.ControlStrategy, TriggerPoints = a.TriggerPoints, @@ -1107,6 +1118,7 @@ namespace YLErp.Modules.RiskEngine { Id = app.id, RuleIds = app.RuleIds, + Description = app.Description, Status = app.Status, ControlStrategy = app.ControlStrategy, TriggerPoints = app.TriggerPoints, @@ -1135,6 +1147,7 @@ namespace YLErp.Modules.RiskEngine var entity = new glms_risk_rule_application { RuleIds = req.RuleIds, + Description = req.Description, Status = RiskRuleStatus.Active, ControlStrategy = req.ControlStrategy, TriggerPoints = req.TriggerPoints, @@ -1172,7 +1185,7 @@ namespace YLErp.Modules.RiskEngine } } - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); return GetApplicationDetail(entity.id); } @@ -1203,6 +1216,7 @@ namespace YLErp.Modules.RiskEngine if (!string.IsNullOrWhiteSpace(req.RuleIds)) app.RuleIds = req.RuleIds; + app.Description = req.Description; app.ControlStrategy = req.ControlStrategy; app.TriggerPoints = req.TriggerPoints; app.ScopeAssetBookIds = req.ScopeAssetBookIds; @@ -1219,7 +1233,7 @@ namespace YLErp.Modules.RiskEngine $"修改应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}"); DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); return GetApplicationDetail(applicationId); } @@ -1240,7 +1254,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_DELETE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "删除应用配置"); DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); } /// @@ -1262,7 +1276,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); } /// @@ -1282,7 +1296,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_DISABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "停用应用配置"); DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); } /// @@ -1313,7 +1327,7 @@ namespace YLErp.Modules.RiskEngine } DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); return new BatchOperationResult { @@ -1346,7 +1360,7 @@ namespace YLErp.Modules.RiskEngine } DbContext.SaveChanges(); - TryRefreshCache(); + RiskEngineService.GetInstance().RefreshApplication(); return new BatchOperationResult { @@ -1534,14 +1548,24 @@ namespace YLErp.Modules.RiskEngine if (variableExprChanged) { + // 编译校验:变量表达式变更后,需验证所有级联更新的规则表达式可编译 + var affectedRules = DbContext.glms_risk_rule + .Where(r => r.RuleExpr != null && r.RuleExpr.Contains(oldVariableExpr)) + .ToList(); + + var compileTasks = affectedRules.Select(r => + Task.Run(() => RuleCompiler.ValidateAndCompileFormula( + r.id, r.RuleExpr.Replace(oldVariableExpr, req.VariableExpr))) + ); + var compileResults = Task.WhenAll(compileTasks).GetAwaiter().GetResult(); + var failed = compileResults.FirstOrDefault(r => !r.Success); + if (failed is not null) + throw new ServiceException($"变量表达式变更导致规则编译失败:{failed.ErrorMessage}"); + using (var transaction = DbContext.Database.BeginTransaction()) { try { - var affectedRules = DbContext.glms_risk_rule - .Where(r => r.RuleExpr != null && r.RuleExpr.Contains(oldVariableExpr)) - .ToList(); - foreach (var rule in affectedRules) { rule.RuleExpr = rule.RuleExpr.Replace(oldVariableExpr, req.VariableExpr); @@ -1570,16 +1594,20 @@ namespace YLErp.Modules.RiskEngine throw; } } + + InvalidateVariableCache(); + foreach (var rule in affectedRules) + { + RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); + } } else { WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); DbContext.SaveChanges(); + InvalidateVariableCache(); } - InvalidateVariableCache(); - TryRefreshCache(); - return GetVariableDetail(variableId); } @@ -1735,4 +1763,4 @@ namespace YLErp.Modules.RiskEngine #endregion } -} +} \ No newline at end of file From 82688fa930c6b9134c2895620448cb405df80e60 Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 13 Jul 2026 15:47:37 +0800 Subject: [PATCH 20/20] =?UTF-8?q?feat:=E9=A3=8E=E6=8E=A7=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=86=85=E5=AD=98=E4=B8=8A=E4=B8=8B=E6=96=87=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=95=B0=E6=8D=AE=E5=BA=93=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E6=B3=A8=E9=87=8A=E4=B8=AD=E7=9A=84=E7=9B=B8=E5=BA=94?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 57 ++++---- YLErpDAL/Modules/RiskEngine/RiskContext.cs | 6 +- .../Modules/RiskEngine/RiskEngineService.cs | 122 ++++++++++-------- .../Modules/RiskModule/QuotaMonitorService.cs | 23 ---- 4 files changed, 101 insertions(+), 107 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index e70e7648..6d48174c 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -1,23 +1,32 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; -using Newtonsoft.Json.Linq; +using Newtonsoft.Json; using System; -using System.Collections.Generic; -using System.Globalization; using System.Linq; +using YLErp.BLL; +using YLErp.QdpModule; namespace YLErp.Modules.RiskEngine { /// /// Roslyn 脚本全局变量容器 - /// CSharpScript 执行时通过此对象传递 DataMap /// public class ScriptGlobals { /// - /// 数据字典:key=表名/前缀,value=对应数据对象 + /// 当前交易ID /// - public Dictionary DataMap { get; set; } + public int TradeId { get; set; } + + /// + /// 当前触发时点 + /// + public string TriggerPoint { get; set; } + + /// + /// 数据库上下文,供规则公式直接查询数据库 + /// + public YLContext DbContext { get; set; } } /// @@ -32,29 +41,12 @@ namespace YLErp.Modules.RiskEngine /// - 可调试:生成的脚本代码可直接阅读和理解 /// /// 示例: - /// RuleExpr: Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m + /// RuleExpr: Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).StockEqvNotional) > 100000000m /// 编译结果:Func<RiskContext, bool>(内部通过 Roslyn 编译缓存) /// public static class RuleCompiler { - /// - /// 变量编码 → 类型全名的映射 - /// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"]) - /// static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); - private static readonly Dictionary VariableTypeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["trade"] = "YLErp.DBModels.trade", - ["swap_position"] = "YLErp.DBModels.swap_position", - ["client"] = "YLErp.DBModels.client", - ["credit"] = "YLErp.DBModels.credit", - ["client_marginrate"] = "YLErp.DBModels.client_marginrate", - ["market"] = "YLErp.DBModels.market", - ["underlying_manager"] = "YLErp.DBModels.underlying_manager", - ["sys"] = "YLErp.DBModels.sys", - ["eod_swap_position"] = "YLErp.DBModels.eod_swap_position", - ["realtime_trade_risk"] = "YLErp.DBModels.realtime_trade_risk", - }; /// /// 校验并编译规则表达式。 @@ -114,7 +106,6 @@ namespace YLErp.Modules.RiskEngine { _logger.Error($"规则校验失败 - RuleId: {rule.Id}, Error: {result.ErrorMessage}"); rule.CompiledScript = null; - RuleCompiledCache.Remove(rule.Id.ToString()); return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}"); } @@ -135,9 +126,14 @@ namespace YLErp.Modules.RiskEngine var options = ScriptOptions.Default .WithReferences( typeof(RiskContext).Assembly, - typeof(YLErp.DBModels.trade).Assembly + typeof(YLContext).Assembly, + typeof(YLErp.DBModels.trade).Assembly, + typeof(QdpCalendarHelper).Assembly, + typeof(JsonConvert).Assembly, + typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly, + typeof(Queryable).Assembly ) - .WithImports("System"); + .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule"); // 创建脚本(尚未执行,仅编译) var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); @@ -162,7 +158,12 @@ namespace YLErp.Modules.RiskEngine { try { - var globals = new ScriptGlobals { DataMap = ctx.DataMap }; + var globals = new ScriptGlobals + { + TradeId = ctx.TradeId, + TriggerPoint = ctx.TriggerPoint, + DbContext = ctx.DbContext + }; return runner(globals).GetAwaiter().GetResult(); } catch (Exception ex) diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs index 7e9e550e..9686f980 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskContext.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using YLErp.BLL; namespace YLErp.Modules.RiskEngine { @@ -18,8 +18,8 @@ namespace YLErp.Modules.RiskEngine public string TriggerPoint { get; set; } /// - /// 数据字典:key=表名/前缀,value=对应数据对象 + /// 数据库上下文,供规则公式直接查询数据库 /// - public Dictionary DataMap { get; set; } = new Dictionary(); + public YLContext DbContext { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index f48bcc78..efd8b91d 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -22,9 +22,9 @@ using YLErp.Model; - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, - 例如直接访问 DataMap["trade"] 后做数值或日期比较。 + 例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。 - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 - - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。 + - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。 - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 当前编译流程: @@ -69,12 +69,12 @@ using YLErp.Model; - RuleCompiledCache.cs:编译结果缓存 当前集成点: - - QuotaMonitorService.cs:构造 RiskContext,并将 trade 对象放入 DataMap["trade"] + - QuotaMonitorService.cs:构造 RiskContext,并传入当前 TradeId 【当前进度】(截至 2026-06-24) ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler - 2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段 + 2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND @@ -102,7 +102,7 @@ using YLErp.Model; 【注意事项】 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 - 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 + 2. RuleExpr 可直接使用 DbContext 查询数据库,当前交易通过 TradeId 定位 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 ================================================================================ @@ -164,16 +164,12 @@ namespace YLErp.Modules.RiskEngine //非活跃的rule不编译 if (rule.Status != RiskRuleStatus.Active) { - _logger.Info($"[风控引擎] 规则非活跃,跳过预编译 - RuleId: {rule.Id}, Status: {rule.Status}"); - continue; - } - //已存在编译缓存的规则直接跳过,避免重复编译 - if (RuleCompiledCache.TryGet(ruleId, out _)) - { - _logger.Info($"[风控引擎] 规则已存在编译缓存,跳过预编译 - RuleId: {rule.Id}"); + RuleCompiledCache.Remove(rule.Id.ToString()); + _logger.Info($"[风控引擎] 规则非活跃,已移除预编译缓存 - RuleId: {rule.Id}, Status: {rule.Status}"); continue; } + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (compileResult.Success) { @@ -193,12 +189,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 刷新缓存:清空已编译委托与内存缓存后重新预加载。 + /// 刷新缓存:刷新规则与应用内存缓存,预编译成功时覆盖旧编译缓存。 /// 规则/应用配置更新后调用。 /// public void RefreshCache() { - _logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载"); + _logger.Info("[风控引擎] RefreshCache 被调用 - 刷新规则与应用缓存并重新预加载"); lock (_cacheLock) { _cachedRules = null; @@ -210,7 +206,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 刷新单条规则缓存:重新加载规则与应用列表,只编译指定规则。 + /// 刷新单条规则缓存:重新加载规则列表,只编译指定规则。 /// public RuleCompileResult RefreshOneRuleCache(long ruleId) { @@ -314,6 +310,10 @@ namespace YLErp.Modules.RiskEngine try { _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); + if (context != null && context.DbContext == null) + { + context.DbContext = DbContext; + } // ============================================================ // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) @@ -343,8 +343,8 @@ namespace YLErp.Modules.RiskEngine // 2. 同一维度内多选按并集处理; // 3. 不同维度之间按交集处理; // 4. 某维度留空表示该维度不限制。 - var trade = context?.DataMap != null && context.DataMap.ContainsKey("trade") - ? context.DataMap["trade"] as YLErp.DBModels.trade + var trade = context != null && context.TradeId > 0 + ? DbContext.trade.FirstOrDefault(t => t.id == context.TradeId) : null; var matchedApplications = triggerMatchedApps @@ -544,8 +544,24 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000001, // RuleName = "挂钩标的集中度校验(本地)", - // RuleText = "取值字段:分子取 QuotaMonitorService 已注入 DataMap[same_underlying_total_notional],该值按存续交易口径汇总同一标的 StockEqvNotional;分母取 DataMap[underlying_manager].IssueSize,债券标的场景下该值由 underlying_manager.ExJson 反序列化回填,含义按当前业务测试口径使用发行量(亿)。为什么这么取:当前上下文已经稳定注入了这两个值,且与集中度规则最接近正式口径。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量需先乘 100000000 还原为元,结果大于 30% 时触发审批。", - // RuleExpr = "DataMap.ContainsKey("same_underlying_total_notional") && DataMap["same_underlying_total_notional"] != null && DataMap.ContainsKey("underlying_manager") && DataMap["underlying_manager"] != null && ((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.HasValue && ((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.Value > 0 && Convert.ToDecimal(DataMap["same_underlying_total_notional"]) / (((YLErp.DBModels.underlying_manager)DataMap["underlying_manager"]).IssueSize.Value * 100000000m) * 100m > 30m", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易;分子查询 trade 表同一标的存续/审批中交易 StockEqvNotional 汇总;分母查询 underlying_manager.ExJson 中债券 IssueSize(亿)。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量乘 100000000 还原为元,结果大于 30% 时触发审批。", + // RuleExpr = "Convert.ToDecimal(DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.UnderlyingId == DbContext.trade.First(x => x.id == TradeId).UnderlyingId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Sum(t => (double?)t.StockEqvNotional) ?? 0d) / (JsonConvert.DeserializeObject(DbContext.underlying_manager.Where(u => u.UnderlyingCode == DbContext.trade.First(x => x.id == TradeId).UnderlyingCode).Select(u => u.ExJson).FirstOrDefault()).IssueSize.Value * 100000000m) * 100m > 30m", + // Version = 1, + // Status = RiskRuleStatus.Active, + // OptId = 0, + // OptName = "system", + // OptDate = DateTime.Now, + // UpdateOptId = 0, + // UpdateOptName = "system", + // UpdateDate = DateTime.Now + //}); + + //rules.Add(new RiskRule + //{ + // Id = 1000003, + // RuleName = "名义本金超阈值(本地)", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StockEqvNotional,对应 trade 表名义本金字段。计算逻辑:StockEqvNotional 大于 100000000 时触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StockEqvNotional > 100000000", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -560,8 +576,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000004, // RuleName = "保证金支付比例超阈值(本地)", - // RuleText = "取值字段:直接取 DataMap[trade].MarginRate,对应 trade 表保证金率字段。为什么这么取:该字段已经在当前上下文稳定注入,且 seed 规则 4 的判断核心也是保证金比例。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).MarginRate > 0.5", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,对应 trade 表保证金率字段。计算逻辑:本地测试按数值型比例直接比较,MarginRate 大于 0.5 视为超过 50%,触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.5", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -576,8 +592,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000005, // RuleName = "保证金利率偏离(本地)", - // RuleText = "取值字段:正式口径应取收益互换扩展数据中的保证金利率字段,但当前 RiskContext 未注入 trade_swap,因此本地测试先取 DataMap[trade].MarginRate 近似代替。为什么这么取:当前上下文只有 trade 可直接取值,先保证规则链路可验证。计算逻辑:若近似保证金利率不在 2% 到 5% 区间内,即小于 0.02 或大于 0.05,则触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((((YLErp.DBModels.trade)DataMap["trade"]).MarginRate < 0.02) || (((YLErp.DBModels.trade)DataMap["trade"]).MarginRate > 0.05))", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate,作为保证金利率本地测试字段。计算逻辑:若 MarginRate 小于 0.02 或大于 0.05,则触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.02 || DbContext.trade.First(t => t.id == TradeId).MarginRate > 0.05", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -592,8 +608,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000006, // RuleName = "保证金收取比例低于最低标准(本地)", - // RuleText = "取值字段:正式口径应取配置项 MinMarginRate 或客户/品种最低保证金率,当前上下文未注入配置对象,因此本地测试仍取 DataMap[trade].MarginRate 做比较。为什么这么取:trade.MarginRate 是当前唯一稳定可得且能反映保证金比例的字段。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).MarginRate < 0.2", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 MarginRate 做本地测试比较。计算逻辑:先以 20% 作为本地测试最低标准,MarginRate 小于 0.2 时触发审批,后续接入正式配置后再替换阈值来源。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).MarginRate < 0.2", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -608,8 +624,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000007, // RuleName = "起息日早于当前日期(本地)", - // RuleText = "取值字段:取 DataMap[trade].StartDate,对应 trade 表开始日。为什么这么取:seed 规则 7 直接使用 StartDate 与当前日期比较,当前上下文也已注入 trade。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap["trade"]).StartDate.Value.Date < DateTime.Today", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate,对应 trade 表开始日。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date < DateTime.Today", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -624,8 +640,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000008, // RuleName = "支付日为银行间交易日(本地)", - // RuleText = "取值字段:取 DataMap[trade].SettlementDate,对应 trade 表结算日期。为什么这么取:当前代码中支付相关日期可直接从 trade 取得,且项目已有 QdpCalendarHelper.GetNonHolidayDefore 可用于交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).SettlementDate.Value.Date", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 SettlementDate,对应 trade 表结算日期;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:若 SettlementDate 有值,且向前修正到最近交易日后的结果不等于原日期,则说明原日期不是银行间交易日,触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).SettlementDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).SettlementDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -640,8 +656,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000009, // RuleName = "到期日为银行间交易日(本地)", - // RuleText = "取值字段:取 DataMap[trade].ExerciseDate,对应当前交易里更接近业务到期/行权日的字段。为什么这么取:TradeBase 中 MaturityDate 注释已提示容易与 ExerciseDate 混淆,当前测试按交易实际到期处理字段 ExerciseDate 落地,避免先取错口径。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).ExerciseDate.Value.Date", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,对应当前交易里更接近业务到期/行权日的字段;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:ExerciseDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -656,8 +672,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000010, // RuleName = "平仓日为银行间交易日(本地)", - // RuleText = "取值字段:取 DataMap[trade].UnWindDate,对应 trade 表平仓日。为什么这么取:当前上下文已注入该字段,且 seed 规则中的平仓日判断在本地最接近该口径。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", - // RuleExpr = "DataMap.ContainsKey("trade") && DataMap["trade"] != null && ((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.Value.Date) != ((YLErp.DBModels.trade)DataMap["trade"]).UnWindDate.Value.Date", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnWindDate,对应 trade 表平仓日;调用 QdpCalendarHelper.GetNonHolidayDefore 做交易日校验。计算逻辑:UnWindDate 有值且向前修正到最近交易日后的结果不等于原日期时,视为不是银行间交易日,触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).UnWindDate.HasValue && QdpCalendarHelper.GetNonHolidayDefore(DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date) != DbContext.trade.First(t => t.id == TradeId).UnWindDate.Value.Date", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -672,8 +688,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000011, // RuleName = "合约期限超阈值(本地)", - // RuleText = "取值字段:取 DataMap[trade].StartDate 和 DataMap[trade].ExerciseDate。为什么这么取:seed 规则 11 本质是计算合约剩余天数,当前 trade 中最稳定可得且最接近交易起止区间的就是开始日和到期/行权日。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.HasValue && (((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate.Value.Date - ((YLErp.DBModels.trade)DataMap[\"trade\"]).StartDate.Value.Date).TotalDays > 365d", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate 和 ExerciseDate。计算逻辑:当 StartDate 和 ExerciseDate 都有值时,用 ExerciseDate.Date 减 StartDate.Date 的总天数,若大于 365 天则触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && (DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date - DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date).TotalDays > 365d", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -688,8 +704,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000012, // RuleName = "债券类净价偏离(本地)", - // RuleText = "取值字段:当前上下文未注入债券估值净价 market.CBValuationPrice,因此本地测试先取 DataMap[trade].SpotPrice 作为可直接获得的价格字段。为什么这么取:债券类正式估值未接入前,需要用交易上已有价格先验证偏离类规则链路。计算逻辑:若 SpotPrice 有值且不为 0,则按 ABS(SpotPrice-100)/100×100% 计算相对面值 100 的偏离率,偏离率大于 5% 时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && Math.Abs(((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value - 100d) / 100d * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiNetNoFeePrice,对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -704,8 +720,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000013, // RuleName = "债券类收益率偏离(本地)", - // RuleText = "取值字段:正式口径应比较 DataMap[trade].InitYtm 与市场估值收益率 market.CBValuationYtm,但当前未注入 market,因此本地测试直接取 trade.InitYtm。为什么这么取:InitYtm 是 trade 上已有且与收益率偏离最接近的字段。计算逻辑:先以 2.5% 作为本地测试基准收益率,若 InitYtm 有值且 ABS(InitYtm-0.025)/0.025×100% 大于 5%,则触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).InitYtm.Value - 0.025d) / 0.025d) * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 InitYtm,对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -720,8 +736,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000014, // RuleName = "非债券类价格偏离(本地)", - // RuleText = "取值字段:正式口径应比较 trade.TradePrice 与市场参考价 market.ReferencePrice,当前未注入 market,因此本地测试直接取 trade.TradePrice 与 trade.SpotPrice 互相比对。为什么这么取:这两个字段都来自 trade,且能够表达成交价相对现价的偏离。计算逻辑:当 TradePrice 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(TradePrice/SpotPrice-1)×100% 计算偏离率,大于 5% 时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).TradePrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value) - 1d) * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiGrossPrice,对应普通收益互换页面填写的期初标的交割全价%,库内为 1 左右原值;通过 DbContext.eod_stock_price / eod_commodity_future_price 按当前交易标的和交易日前日期取上一日收盘价 ClosePrice。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiGrossPrice * 100m - Convert.ToDecimal((DbContext.eod_stock_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).Select(e => (double?)e.ClosePrice).FirstOrDefault() ?? DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).Select(e => (double?)e.ClosePrice).FirstOrDefault()).Value)) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -736,8 +752,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000015, // RuleName = "单一交易对手累计标的数量超阈值(本地)", - // RuleText = "取值字段:取 DataMap[trade].ClientId 作为交易对手标识,并在表达式里直接查询 trade 表的 UnderlyingId。为什么这么取:当前上下文尚未预先注入该聚合值,但 DbContext 在脚本环境可用,且项目已有存续口径可以复用。计算逻辑:按 ValidState 不等于 InValid、ParentTradeId 等于 0、TradeStatus 属于 NeedMarginTradeStatusList 或审批中 的存续口径,统计同一 ClientId 下去重后的 UnderlyingId 数量,超过 10 个时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.ClientId == ((YLErp.DBModels.trade)DataMap[\"trade\"]).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Select(t => t.UnderlyingId).Distinct().Count() > 10", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手存续/审批中交易的去重标的数量。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", + // RuleExpr = "DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Select(t => t.UnderlyingId).Distinct().Count() > 10", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -752,8 +768,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000016, // RuleName = "多头支付固定端利率偏离(本地)", - // RuleText = "取值字段:正式口径应同时取 trade_swap 固定端方向、固定利率以及市场基准利率,当前未注入 trade_swap 和 market,因此本地测试先取 DataMap[trade].FixedRate,并结合 BuySell 判断多头方向。为什么这么取:FixedRate 和 BuySell 都已在 trade 上可取,能先验证方向类利率规则链路。计算逻辑:当 BuySell 表示多头且 FixedRate 有值时,先以 2.5% 作为本地测试基准,若 ABS(FixedRate-0.025)/0.025×100% 大于 5%,则触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Buy\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.Value - 0.025d) / 0.025d) * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 FixedRate,并结合 BuySell 判断多头方向。计算逻辑:当 BuySell 表示多头且 FixedRate 有值时,先以 2.5% 作为本地测试基准,若 ABS(FixedRate-0.025)/0.025×100% 大于 5%,则触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).BuySell == \"Buy\" && DbContext.trade.First(t => t.id == TradeId).FixedRate.HasValue && Math.Abs((DbContext.trade.First(t => t.id == TradeId).FixedRate.Value - 0.025d) / 0.025d) * 100d > 5d", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -768,8 +784,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000017, // RuleName = "空头利率减点借贷加权偏离(本地)", - // RuleText = "取值字段:正式口径应取 trade_swap 空头端利率减点、借贷成本和加权基准,当前未注入这些对象,因此本地测试仍取 DataMap[trade].FixedRate,并结合 BuySell 判断空头方向。为什么这么取:当前 trade 上只有 FixedRate 可稳定表达利率数值,先用于验证空头分支规则。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Sell\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.HasValue && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).FixedRate.Value - 0.02d) / 0.02d) * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 FixedRate,并结合 BuySell 判断空头方向。计算逻辑:当 BuySell 表示空头且 FixedRate 有值时,先以 2% 作为本地测试基准,若 ABS(FixedRate-0.02)/0.02×100% 大于 5%,则触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).BuySell == \"Sell\" && DbContext.trade.First(t => t.id == TradeId).FixedRate.HasValue && Math.Abs((DbContext.trade.First(t => t.id == TradeId).FixedRate.Value - 0.02d) / 0.02d) * 100d > 5d", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -784,8 +800,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000018, // RuleName = "账户授权收支方向不匹配(本地)", - // RuleText = "取值字段:正式口径应取账户授权配置中的收支方向和当前交易实际收支方向,当前上下文未注入账户授权对象,因此本地测试先用 trade.OpponentRole 与 trade.BuySell 做占位判断。为什么这么取:当前只有 trade 上的方向类字段可直接取得,先用于验证禁止类规则是否能命中。计算逻辑:当 OpponentRole 和 BuySell 都有值,且出现本地定义的不允许组合时触发禁止;当前测试口径先将 OpponentRole 为 Pay 且 BuySell 为 Buy 视为方向不匹配。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole) && !string.IsNullOrWhiteSpace(((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell) && ((YLErp.DBModels.trade)DataMap[\"trade\"]).OpponentRole == \"Pay\" && ((YLErp.DBModels.trade)DataMap[\"trade\"]).BuySell == \"Buy\"", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OpponentRole 与 BuySell 做本地测试占位判断。计算逻辑:当 OpponentRole 和 BuySell 都有值,且 OpponentRole 为 Pay 且 BuySell 为 Buy 时视为方向不匹配,触发禁止。", + // RuleExpr = "!string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).OpponentRole) && !string.IsNullOrWhiteSpace(DbContext.trade.First(t => t.id == TradeId).BuySell) && DbContext.trade.First(t => t.id == TradeId).OpponentRole == \"Pay\" && DbContext.trade.First(t => t.id == TradeId).BuySell == \"Buy\"", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -800,8 +816,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000019, // RuleName = "执行价偏离超阈值(本地)", - // RuleText = "取值字段:取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice,分别对应行权价与现价。为什么这么取:当前项目已有使用 Strike 与 SpotPrice 做偏离判断的场景,且这两个字段都已在 trade 上可直接获取。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value) - 1d) * 100d > 5d", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,分别对应行权价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 SpotPrice 不为 0 时,按 ABS(Strike/SpotPrice-1)×100% 计算执行价相对现价的偏离率,大于 5% 时触发审批。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).Strike.Value / DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value) - 1d) * 100d > 5d", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -816,8 +832,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000021, // RuleName = "接近/触发敲入敲出价(本地)", - // RuleText = "取值字段:正式口径应取产品条款中的敲入价/敲出价以及现价,当前上下文未注入条款对象,因此本地测试先取 DataMap[trade].Strike 和 DataMap[trade].SpotPrice 近似模拟触发价与现价。为什么这么取:这两个字段当前即可直接取得,适合先验证提示类规则链路。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。", - // RuleExpr = "DataMap.ContainsKey(\"trade\") && DataMap[\"trade\"] != null && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.HasValue && ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value != 0 && Math.Abs((((YLErp.DBModels.trade)DataMap[\"trade\"]).SpotPrice.Value / ((YLErp.DBModels.trade)DataMap[\"trade\"]).Strike.Value) - 1d) * 100d <= 2d", + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 Strike 和 SpotPrice,近似模拟触发价与现价。计算逻辑:当 Strike 和 SpotPrice 都有值且 Strike 不为 0 时,按 ABS(SpotPrice/Strike-1)×100% 计算两者距离,距离小于等于 2% 时视为接近触发价,给出提示。", + // RuleExpr = "DbContext.trade.First(t => t.id == TradeId).Strike.HasValue && DbContext.trade.First(t => t.id == TradeId).SpotPrice.HasValue && DbContext.trade.First(t => t.id == TradeId).Strike.Value != 0 && Math.Abs((DbContext.trade.First(t => t.id == TradeId).SpotPrice.Value / DbContext.trade.First(t => t.id == TradeId).Strike.Value) - 1d) * 100d <= 2d", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 41b62b1b..a0817047 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5183,29 +5183,6 @@ namespace YLErp.Modules.RiskModule TradeId = tradeId, TriggerPoint = "BOOK_CONFIRM" }; - riskContext.DataMap["trade"] = tradeObj; - var sameUnderlyingTotalNotional = DbContext.trade - .Where(t => t.ValidState != "InValid" - && t.UnderlyingId == tradeObj.UnderlyingId - && t.ParentTradeId == 0 - && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == "审批中")) - .Sum(t => (double?)t.StockEqvNotional) ?? 0d; - riskContext.DataMap["same_underlying_total_notional"] = sameUnderlyingTotalNotional; - var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(tradeObj.UnderlyingCode); - if (underlying != null) - { - if (underlying.IsBond() && !string.IsNullOrEmpty(underlying.ExJson)) - { - var bond = JsonHelper.Deserialize(underlying.ExJson); - if (bond != null) - { - underlying.IssueSize = bond.IssueSize; - underlying.UnderlyingFullName = bond.UnderlyingFullName; - underlying.UnderlyingIssuer = bond.UnderlyingIssuer; - } - } - riskContext.DataMap["underlying_manager"] = underlying; - } var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM"); _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}");