From 8fb3c4392ce8e3ad95539f1193ed42a3ef526a63 Mon Sep 17 00:00:00 2001 From: ruisu Date: Wed, 5 Aug 2026 14:39:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E9=A3=8E=E6=8E=A7=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=97=B6=E6=98=BE=E7=A4=BA=E5=BA=94=E7=94=A8ID=E3=80=81=20?= =?UTF-8?q?=E6=96=B0=E8=80=81=E9=A3=8E=E6=8E=A7=E7=BB=84=E5=90=88=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=9A=20=E8=80=81=E9=A3=8E=E6=8E=A7Error/Warning?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E9=A3=8E=E6=8E=A7=E9=9C=80=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=82=B9=E5=87=BB=E7=89=B9=E6=89=B9=E5=8D=B3?= =?UTF-8?q?=E4=B8=A4=E8=BE=B9=E9=83=BD=E6=94=BE=E8=A1=8C=20=E8=80=81?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7Error/Warning=EF=BC=8C=E6=96=B0=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E7=A6=81=E6=AD=A2=E2=80=94=E2=80=94=E4=B8=8D=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=20=E8=80=81=E9=A3=8E=E6=8E=A7Error/Warning=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=B1=95=E7=A4=BA=E7=89=B9=E6=89=B9=EF=BC=8C=E4=B8=94?= =?UTF-8?q?=E5=8F=AA=E7=89=B9=E6=89=B9=E8=80=81=E9=A3=8E=E6=8E=A7=E3=80=82?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E9=A3=8E=E6=8E=A7=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Framework/YLErp.Core/DBModels/QuotaTrial.cs | 12 + .../RiskEngine/Compile/RuleCompiler.cs | 1 - .../RiskEngine/Helper/RiskCalendarHelper.cs | 30 +- .../Modules/RiskEngine/RiskEngineService.cs | 323 +----------------- YLErpDAL/Modules/RiskEngine/RiskResult.cs | 1 + .../RiskEngine/StructuredRuleExecutor.cs | 106 +++++- .../Modules/RiskModule/QuotaMonitorService.cs | 21 +- .../DealModule/TradeConfirmService.cs | 14 + .../DealModule/TradeOpenService.cs | 10 + YLErpDAL/Modules/TradeModule/TradeBLL.cs | 5 + YLErpWeb/Controllers/tradeController.cs | 36 +- .../Scripts/app/swaptrade/swapTradeView.js | 14 +- 12 files changed, 226 insertions(+), 347 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/QuotaTrial.cs b/Framework/YLErp.Core/DBModels/QuotaTrial.cs index 650af814..8daec72a 100644 --- a/Framework/YLErp.Core/DBModels/QuotaTrial.cs +++ b/Framework/YLErp.Core/DBModels/QuotaTrial.cs @@ -69,5 +69,17 @@ namespace YLErp.DBModels [NotMapped] public QuotaTrialStatusEnum? OldRiskTrialStatus { get; set; } + + /// + /// 新风控是否命中禁止规则 + /// + [NotMapped] + public bool NewRiskBlocked { get; set; } + + /// + /// 新风控是否命中需审批规则 + /// + [NotMapped] + public bool NewRiskNeedApproval { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index d827a6db..c8b74717 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -360,7 +360,6 @@ namespace YLErp.Modules.RiskEngine .WithReferences( typeof(RiskContext).Assembly, typeof(RiskCalendarHelper).Assembly, - typeof(RiskMarketDeviationHelper).Assembly, typeof(YLContext).Assembly, typeof(YLErp.DBModels.trade).Assembly, typeof(QdpCalendarHelper).Assembly, diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index c2b4758c..6a02e77a 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -9,7 +9,7 @@ namespace YLErp.Modules.RiskEngine { /// /// 风控规则专用日历辅助类。 - /// 当前主要用于债券类规则按银行间日历确认“上一收盘日”,避免简单按估值表倒序取最近日期导致口径偏差。 + /// 用于按规则对应的市场日历确认“上一收盘日”,避免简单按行情表倒序取最近日期导致口径偏差。 /// public static class RiskCalendarHelper { @@ -22,6 +22,16 @@ namespace YLErp.Modules.RiskEngine /// 数据库上下文为空。 /// 缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。 public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date) + { + return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); + } + + public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) + { + return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); + } + + private static DateTime GetPreviousTradingDay(YLContext dbContext, DateTime date, string country, string calendarName) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); @@ -32,17 +42,17 @@ namespace YLErp.Modules.RiskEngine // 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。 for (var i = 0; i < 370; i++) { - var holidays = GetInterbankHolidays(dbContext, currentDate.Year, holidayCache); + var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); - // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是银行间交易日。 + // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 if (!holidays.Contains(currentDateText)) return currentDate; currentDate = currentDate.AddDays(-1); } - throw new Exception($"未找到{date:yyyy-MM-dd}的上一银行间交易日"); + throw new Exception($"未找到{date:yyyy-MM-dd}的上一{calendarName}交易日"); } /// @@ -52,22 +62,22 @@ namespace YLErp.Modules.RiskEngine /// 日历年份。 /// 单次查询过程内的年份级缓存,跨年查找时避免重复读取同一年日历。 /// 格式为 yyyy,MM,dd 的非交易日集合。 - private static HashSet GetInterbankHolidays(YLContext dbContext, int year, Dictionary> holidayCache) + private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { if (holidayCache.TryGetValue(year, out var holidays)) return holidays; - // 同一年可能存在多种市场日历;规则 12 明确使用 Country=IB 的银行间日历。 + // 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日。 var calendar = dbContext.calendar .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) .ToList() - .FirstOrDefault(c => string.Equals(c.Country, "IB", StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase)); if (calendar == null) - throw new Exception($"未找到{year}年银行间日历"); + throw new Exception($"未找到{year}年{calendarName}日历"); if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) - throw new Exception($"{year}年银行间日历HolidayJson为空"); + throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); List holidayList; try @@ -76,7 +86,7 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - throw new Exception($"{year}年银行间日历HolidayJson解析失败", ex); + throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } holidays = new HashSet(holidayList ?? new List()); diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 58e95978..1d4ef0f0 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -467,7 +467,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -478,7 +478,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } } @@ -496,7 +496,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, rule.Id.ToString(), application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -515,7 +515,7 @@ namespace YLErp.Modules.RiskEngine { var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + AddBlockError(result, ruleId, application.Id, rule.RuleName, rule.RuleText, errorMessage); continue; } } @@ -537,6 +537,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = rule.Id.ToString(), + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Block, RuleText = rule.RuleText, @@ -550,6 +551,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Approval, RuleText = rule.RuleText, @@ -562,6 +564,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = application.Id, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.ShowTip, RuleText = rule.RuleText, @@ -598,7 +601,7 @@ namespace YLErp.Modules.RiskEngine catch (Exception ex) { var errorMessage = $"风控引擎异常:{ex.Message}"; - AddBlockError(result, "ENGINE_ERROR", "风控引擎执行异常", ex.Message, errorMessage); + AddBlockError(result, "ENGINE_ERROR", null, "风控引擎执行异常", ex.Message, errorMessage); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } @@ -612,7 +615,7 @@ namespace YLErp.Modules.RiskEngine : $"{baseMessage}。{detailMessage}"; } - private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage) + private static void AddBlockError(RiskResult result, string ruleId, long? applicationId, string ruleName, string ruleText, string errorMessage) { result.Blocked = true; result.Passed = false; @@ -622,6 +625,7 @@ namespace YLErp.Modules.RiskEngine result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, + ApplicationId = applicationId, RuleName = ruleName, ControlStrategy = RiskControlStrategy.Block, RuleText = ruleText, @@ -671,313 +675,6 @@ namespace YLErp.Modules.RiskEngine } return rules; - //#region 测试本地规则 - //rules.Add(new RiskRule - //{ - // Id = 1000001, - // RuleName = "挂钩标的集中度校验(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000004, - // RuleName = "保证金支付比例超阈值(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000005, - // RuleName = "保证金利率偏离(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000006, - // RuleName = "保证金收取比例低于最低标准(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000007, - // RuleName = "起息日早于当前日期(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000008, - // RuleName = "支付日为银行间交易日(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000009, - // RuleName = "到期日为银行间交易日(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000010, - // RuleName = "平仓日为银行间交易日(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000011, - // RuleName = "合约期限超阈值(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000012, - // RuleName = "债券类净价偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", - // 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 = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", - // 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 = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", - // 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 = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", - // RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).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 = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。计算逻辑:按 ABS(InterestRateDefault×100) 计算点差百分比绝对值,绝对值小于 5 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault * 100m) < 5m", - // 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 = "取值字段:通过 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000018, - // RuleName = "账户授权收支方向不匹配(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000019, - // RuleName = "执行价偏离超阈值(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //rules.Add(new RiskRule - //{ - // Id = 1000021, - // RuleName = "接近/触发敲入敲出价(本地)", - // 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, - // OptName = "system", - // OptDate = DateTime.Now, - // UpdateOptId = 0, - // UpdateOptName = "system", - // UpdateDate = DateTime.Now - //}); - - //return rules; - //#endregion } diff --git a/YLErpDAL/Modules/RiskEngine/RiskResult.cs b/YLErpDAL/Modules/RiskEngine/RiskResult.cs index cbc12d79..4069ad52 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskResult.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskResult.cs @@ -44,6 +44,7 @@ namespace YLErp.Modules.RiskEngine public class TriggeredRuleInfo { public string RuleId { get; set; } + public long? ApplicationId { get; set; } public string RuleName { get; set; } public RiskControlStrategy ControlStrategy { get; set; } public string RuleText { get; set; } diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 7ba84352..73c8cb7d 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -331,6 +331,7 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}"); converted.DetailMessage = detail?.Message; + converted.DetailItems = detail?.Items; return converted; } @@ -425,12 +426,77 @@ namespace YLErp.Modules.RiskEngine private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) { var parts = new List(); - AddDetail(parts, left.DetailMessage); - parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}"); + parts.Add($"触发判断:{variable.VariableName}为{FormatDisplayValue(left.Value)},阈值为{FormatDisplayValue(right.Value)},比较关系:{GetOperatorName(ruleOperator)}"); + AddCompareDetails(parts, variable, left, right, ruleOperator); AddDetail(parts, right.DetailMessage); - parts.Add($"阈值为{FormatDisplayValue(right.Value)}"); - parts.Add($"比较关系:{GetOperatorName(ruleOperator)}"); - return string.Join(",", parts); + return string.Join(";", parts); + } + + /// + /// 按当前规则阈值重新判断变量返回的逐条明细,并将明细拆分为超过阈值和未超过阈值两组。 + /// 该逻辑只在规则已经命中且需要构造提示时执行,不参与规则主判断;未提供逐条明细时保留原始明细文本。 + /// + private static void AddCompareDetails(List parts, glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) + { + var detailItems = left.DetailItems; + if (detailItems == null || detailItems.Count == 0) + { + AddDetail(parts, left.DetailMessage); + return; + } + + AddDetail(parts, left.DetailMessage); + var triggeredItems = new List(detailItems.Count); + var untriggeredItems = new List(); + foreach (var detailItem in detailItems) + { + if (detailItem == null || string.IsNullOrWhiteSpace(detailItem.Message)) + continue; + if (!TryEvaluateDetailTrigger(detailItem.Value, right.Value, variable.DataType, ruleOperator, out var triggered)) + continue; + + if (triggered) + triggeredItems.Add(detailItem.Message); + else + untriggeredItems.Add(detailItem.Message); + } + + if (triggeredItems.Count > 0) + parts.Add($"{variable.VariableName}超过阈值的记录:" + string.Join(";", triggeredItems)); + if (untriggeredItems.Count > 0) + parts.Add($"{variable.VariableName}未超过阈值的其他偏离记录:" + string.Join(";", untriggeredItems)); + } + + /// + /// 复用普通比较条件的类型转换口径,判断单条变量明细是否满足当前条件。 + /// 为减少命中提示阶段的额外开销,这里只做一次明细值转换,并直接用 CompareTo 完成比较。 + /// 返回 false 表示明细值无法转换或操作符不适用于当前类型,此类异常明细不影响主规则结果。 + /// + private static bool TryEvaluateDetailTrigger(object detailValue, object thresholdValue, RiskVariableDataType dataType, string ruleOperator, out bool triggered) + { + triggered = false; + if (dataType != RiskVariableDataType.Numeric && dataType != RiskVariableDataType.Date) + return false; + + var itemValue = ConvertValue(detailValue, dataType, "明细", "明细值"); + if (!itemValue.Success) + return false; + + var compare = dataType == RiskVariableDataType.Numeric + ? ((decimal)itemValue.Value).CompareTo((decimal)thresholdValue) + : ((DateTime)itemValue.Value).CompareTo((DateTime)thresholdValue); + + triggered = ruleOperator switch + { + "gt" => compare > 0, + "lt" => compare < 0, + "gte" => compare >= 0, + "lte" => compare <= 0, + "eq" => compare == 0, + "ne" => compare != 0, + _ => false + }; + return ruleOperator is "gt" or "lt" or "gte" or "lte" or "eq" or "ne"; } /// @@ -664,10 +730,35 @@ namespace YLErp.Modules.RiskEngine /// /// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。 + /// 仅保证金等需要逐条判断的变量应返回 Items,普通单交易变量只需返回 Value 和当前取值来源说明。 /// public class RiskVariableValueDetail { public RiskVariableValueDetail(object value, string message) + : this(value, message, null) + { + } + + public RiskVariableValueDetail(object value, string message, IEnumerable items) + { + Value = value; + Message = message; + Items = items?.ToList() ?? new List(); + } + + public object Value { get; } + + public string Message { get; } + + public IReadOnlyList Items { get; } + } + + /// + /// 需要遍历的变量所返回的单条计算明细。Value 用于按规则阈值复判该条明细是否命中,Message 用于展示该条记录。 + /// + public class RiskVariableValueDetailItem + { + public RiskVariableValueDetailItem(object value, string message) { Value = value; Message = message; @@ -692,6 +783,11 @@ namespace YLErp.Modules.RiskEngine /// public string DetailMessage { get; set; } + /// + /// 变量表达式返回的逐条计算明细,仅用于命中提示阶段按当前阈值拆分展示。 + /// + public IReadOnlyList DetailItems { get; set; } + public string ErrorMessage { get; set; } public static ValueExecuteResult Ok(object value) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index e090ee27..e2f7c1f4 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4491,6 +4491,7 @@ namespace YLErp.Modules.RiskModule } var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; + _logger.Info($"TradeConfirmRisk.RunFullRisk tradeId:{tradeId}, sourceTradeId:{res.Trade.id}, tradeNumber:{res.Trade.TradeNumber}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, userId:{OptUser?.UserId}"); var trialService = new QuotaMonitorService(this); var quotaObj = trialService.RunQuotaTrial(tradeId, 1); return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); @@ -4513,6 +4514,7 @@ namespace YLErp.Modules.RiskModule } var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; + _logger.Info($"TradeConfirmRisk.RunNewRisk tradeId:{tradeId}, sourceTradeId:{res.Trade.id}, tradeNumber:{res.Trade.TradeNumber}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, userId:{OptUser?.UserId}"); var trialService = new QuotaMonitorService(this); var quotaObj = trialService.RunNewRiskTrial(tradeId, 1); return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); @@ -4524,6 +4526,7 @@ namespace YLErp.Modules.RiskModule /// private bool ApplyQuotaCheckResult(ref TradeOpenResult res, QuotaTrial quotaObj, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) { + _logger.Info($"TradeConfirmRisk.TrialResult tradeId:{res.Trade?.id}, tradeNumber:{res.Trade?.TradeNumber}, trialDataId:{quotaObj.id}, trialStatus:{quotaObj.TrialStatus}, oldRiskTrialStatus:{quotaObj.OldRiskTrialStatus}, newRiskNeedApproval:{quotaObj.NewRiskNeedApproval}, newRiskBlocked:{quotaObj.NewRiskBlocked}, approvalRuleIds:{string.Join(",", quotaObj.ApprovalRuleIds ?? new List())}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{string.Join(",", ignoreRiskRuleIds ?? Enumerable.Empty())}, fundCheck:{!string.IsNullOrWhiteSpace(quotaObj.FundCheckDetails)}, quotaCheck:{!string.IsNullOrWhiteSpace(quotaObj.QuotaCheckDetails)}, riskWarning:{!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)}, quotaWarning:{!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)}, userId:{OptUser?.UserId}"); if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success)//这次计算结果是通过,直接通过 { if (!string.IsNullOrWhiteSpace(quotaObj.AvailableForClient)) // 返回用户资金情况 @@ -4554,11 +4557,15 @@ 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.RiskWarning + // 向上层透传新风控原始结果,用于区分组合特批和禁止场景。 + res.NewRiskBlocked = quotaObj.NewRiskBlocked; + res.NewRiskNeedApproval = quotaObj.NewRiskNeedApproval; + // 新风控禁止优先阻断;需审批状态独立判断,避免被老风控的Error或Warning状态覆盖。 + var isRiskApprovalWarning = quotaObj.NewRiskNeedApproval + && !quotaObj.NewRiskBlocked && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails) && (quotaObj.ApprovalRuleIds?.Any() ?? false); - //老风控试算不通过时,延用master逻辑进入交易特批/二次处理;新风控需审批由RiskWarning单独处理 + // 老风控试算不通过时,延用master逻辑进入交易特批;与新风控需审批同时命中时保留两边状态。 res.OldRiskNeedSpecialApproval = quotaObj.OldRiskTrialStatus.HasValue && quotaObj.OldRiskTrialStatus.Value != QuotaTrialStatusEnum.Success; if (isRiskApprovalWarning) @@ -4573,6 +4580,7 @@ namespace YLErp.Modules.RiskModule var newApprovalRuleIds = currentApprovalRuleIds.Where(o => !ignoredRuleIdSet.Contains(o)).ToList(); if (!newApprovalRuleIds.Any()) { + _logger.Info($"TradeConfirmRisk.ApprovalIgnored tradeId:{res.Trade?.id}, tradeNumber:{res.Trade?.TradeNumber}, trialDataId:{quotaObj.id}, ignoredRuleIds:{string.Join(",", ignoredRuleIdSet)}, userId:{OptUser?.UserId}"); res.ErrorMsg = string.Empty; return true; } @@ -5343,6 +5351,10 @@ namespace YLErp.Modules.RiskModule _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}"); + // 单独保留新风控原始结果,避免与老风控状态合并后无法区分组合特批和禁止场景。 + result.NewRiskBlocked = riskResult.Blocked; + result.NewRiskNeedApproval = riskResult.NeedApproval; + // 按策略映射回 QuotaTrialStatusEnum if (riskResult.Blocked) { @@ -5402,7 +5414,8 @@ namespace YLErp.Modules.RiskModule /// private static string BuildTriggeredRuleDetail(TriggeredRuleInfo triggeredRule) { - var detail = $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; + var applicationIdText = triggeredRule.ApplicationId?.ToString() ?? "-"; + var detail = $"应用ID:{applicationIdText};规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; if (!string.IsNullOrWhiteSpace(triggeredRule.Message)) { detail += $";信息:{triggeredRule.Message}"; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index 018cac68..a10d96ac 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -122,6 +122,9 @@ namespace YLErp.Modules.TradeModule.DealModule { result.LackOfMoney = true; result.OldRiskNeedSpecialApproval = temp.OldRiskNeedSpecialApproval; + // 保留新风控独立状态,供上层决定展示组合特批或仅展示禁止详情。 + result.NewRiskBlocked = temp.NewRiskBlocked; + result.NewRiskNeedApproval = temp.NewRiskNeedApproval; result.TrialDataId = temp.TrialDataId; result.TradeId = temp.Trade.id; result.ignoreRiskRuleIds = temp.IgnoredRiskRuleIds; @@ -634,6 +637,17 @@ namespace YLErp.Modules.TradeModule.DealModule { public bool LackOfMoney; public bool OldRiskNeedSpecialApproval; + + /// + /// 新风控是否命中禁止规则 + /// + public bool NewRiskBlocked; + + /// + /// 新风控是否命中需审批规则 + /// + public bool NewRiskNeedApproval; + public int TrialDataId; public int TradeId; public List confirmedTradeIds; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 8bfd988e..ce3a1fc2 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -797,6 +797,16 @@ namespace YLErp.Modules.TradeModule.DealModule public List IgnoredRiskRuleIds; public bool OldRiskNeedSpecialApproval; + /// + /// 新风控是否命中禁止规则 + /// + public bool NewRiskBlocked; + + /// + /// 新风控是否命中需审批规则 + /// + public bool NewRiskNeedApproval; + /// /// 错误信息 /// diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index e91abbae..2efe1960 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -27,6 +27,11 @@ namespace YLErp.BLL public const string RiskWarningConfirm = "RiskWarningConfirm"; + /// + /// 老风控交易特批与新风控需审批同时放行 + /// + public const string OldNewRiskConfirm = "OldNewRiskConfirm"; + public const string IgnoreBreak = "IgnoreBreak"; /// diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 827bc8a6..12de7016 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2464,12 +2464,14 @@ namespace YLErp.Web.Controllers { riskWarningConfirmExpireSeconds = configuredRiskWarningConfirmExpireSeconds; } - var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; - var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; + // 组合特批由单独类型明确表达,避免只有老风控异常时误放行新风控。 + var isOldNewRiskConfirm = additionalProcessing == tradeBLL.OldNewRiskConfirm; + var ignoreMoneyCheck = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.LackOfMoney || isOldNewRiskConfirm); + var ignoreRiskWarning = tradeidArr.Count() == 1 && (additionalProcessing == tradeBLL.RiskWarningConfirm || isOldNewRiskConfirm); var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); - // 老风控交易特批与新风控二次审批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 - // 当前仅 swapTradeView.js 会回传 trialDataId,按最近一次“触发风控”日志时间校验;查不到日志时回退 quotaTrial.OptDate。 - // tradeview.js / tradeConfirmList.js / swapTradeConfirmList.js / quotaMonitor.js 只发 LackOfMoney,不传 trialDataId,不进入超时校验。 + _logger.Info($"TradeConfirmRisk.Request tradeids:{tradeids}, additionalProcessing:{additionalProcessing}, trialDataId:{trialDataId}, ignoreMoneyCheck:{ignoreMoneyCheck}, ignoreRiskWarning:{ignoreRiskWarning}, ignoreRiskRuleIds:{ignoreRiskRuleIds}, isSkipCheck:{isSkipCheck}, userId:{CurUser?.UserId}"); + // 老风控交易特批、新风控审批及组合特批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 + // tradeview.js 和 swapTradeView.js 会回传 trialDataId;未回传的旧入口保持原有行为,不进入超时校验。 if (ignoreMoneyCheck || ignoreRiskWarning) { var trialExpired = false; @@ -2480,6 +2482,7 @@ namespace YLErp.Web.Controllers } if (trialExpired) { + _logger.Info($"TradeConfirmRisk.TrialExpired tradeids:{tradeids}, additionalProcessing:{additionalProcessing}, trialDataId:{trialDataId}, expireSeconds:{riskWarningConfirmExpireSeconds}, userId:{CurUser?.UserId}"); ignoreMoneyCheck = false; ignoreRiskWarning = false; ignoreRiskRuleIdArr = Array.Empty(); @@ -2492,20 +2495,33 @@ namespace YLErp.Web.Controllers isSkipApproval = true; } var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr); + _logger.Info($"TradeConfirmRisk.Result tradeids:{tradeids}, lackOfMoney:{result.LackOfMoney}, type:{result.type}, trialDataId:{result.TrialDataId}, oldRiskNeedSpecialApproval:{result.OldRiskNeedSpecialApproval}, newRiskNeedApproval:{result.NewRiskNeedApproval}, newRiskBlocked:{result.NewRiskBlocked}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List())}, errorMsgLength:{result.errorMsg?.Length ?? 0}, tipMsgLength:{result.tipMsg?.Length ?? 0}, userId:{CurUser?.UserId}"); //如果需要前端确认信息,触发新老风控 if (result.LackOfMoney) { - if (result.type == TradeOpenRetCode.RiskWarning.ToString()) + // 新风控禁止优先级最高,即使老风控允许特批也只展示详情,不提供特批入口。 + if (result.NewRiskBlocked) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "RiskWarning", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type, ignoreRiskRuleIds = result.ignoreRiskRuleIds }); + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:QuotaTrialError, source:NewRiskBlocked, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + // 老风控Error会使最终类型保持QuotaTrialError,因此组合场景必须按新风控原始审批状态识别。 + if (result.NewRiskNeedApproval || result.type == TradeOpenRetCode.RiskWarning.ToString()) + { + // 老风控异常叠加新风控需审批时,一次特批同时放行两边;否则仍只审批新风控。 + var isOldNewRiskConfirmRequired = result.OldRiskNeedSpecialApproval && result.NewRiskNeedApproval; + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:{(isOldNewRiskConfirmRequired ? "OldNewRisk" : "RiskWarning")}, type:{(isOldNewRiskConfirmRequired ? tradeBLL.OldNewRiskConfirm : tradeBLL.RiskWarningConfirm)}, typecode:{result.type}, trialDataId:{result.TrialDataId}, ignoreRiskRuleIds:{string.Join(",", result.ignoreRiskRuleIds ?? new List())}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = isOldNewRiskConfirmRequired ? "OldNewRisk" : "RiskWarning", type = isOldNewRiskConfirmRequired ? tradeBLL.OldNewRiskConfirm : tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type, ignoreRiskRuleIds = result.ignoreRiskRuleIds }); } if (result.type == TradeOpenRetCode.FundStatus.ToString()) { + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:FundStatus, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); 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))) { + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:LackOfMoney, type:{tradeBLL.LackOfMoney}, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "LackOfMoney", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } //限额试算不通过时,延用master老逻辑:老风控走AdditionalProcessing并允许前端展示“交易特批”;新风控检查失败仍只展示详情不允许审批 @@ -2513,8 +2529,11 @@ namespace YLErp.Web.Controllers { if (result.OldRiskNeedSpecialApproval) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "OldRiskQuotaTrialError", type = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + var oldRiskSpecialApprovalType = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : ""; + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:AdditionalProcessing, source:OldRiskQuotaTrialError, type:{oldRiskSpecialApprovalType}, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); + return JsonSuccessData(new { proccessType = "AdditionalProcessing", source = "OldRiskQuotaTrialError", type = oldRiskSpecialApprovalType, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } + _logger.Info($"TradeConfirmRisk.Response tradeids:{tradeids}, proccessType:QuotaTrialError, source:QuotaTrialError, typecode:{result.type}, trialDataId:{result.TrialDataId}, userId:{CurUser?.UserId}"); return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } } @@ -2553,6 +2572,7 @@ namespace YLErp.Web.Controllers } if (!string.IsNullOrWhiteSpace(decision)) { + _logger.Info($"TradeConfirmRisk.Decision tradeids:{tradeids}, trialDataId:{trialDataId}, decision:{decision}, userId:{CurUser?.UserId}"); new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(trialDataId.Value, decision); } } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index d0759efc..01f84b73 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -71,7 +71,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (data.obj.TrialDataId) { var quotaTrialErrorLayerSetting = { type: 2, - title: "提示", + title: "风控试算详情", shadeClose: false, shade: 0.4, area: ['800px', '500px'], @@ -98,12 +98,14 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (data.obj.TrialDataId) { // 交易特批来源,优先使用后端新增的 source 字段区分老风控/新风控,同时兼容旧返回结构。 var additionalProcessingSource = data.obj.source || ""; + // 新老风控组合特批必须原样回传 OldNewRiskConfirm,二次确认时才能同时放行两套风控。 + var isOldNewRiskConfirm = data.obj.type === "OldNewRiskConfirm" || additionalProcessingSource === "OldNewRisk"; // 新风控需审批场景: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"; + var isOldRiskSpecialApproval = additionalProcessingSource === "OldRiskQuotaTrialError" || data.obj.type === "LackOfMoney" || isOldNewRiskConfirm; + // 后端已区分单独审批和组合特批,前端原样提交处理类型,避免组合特批降级为仅放行老风控。 + var additionalProcessingType = data.obj.type; // 新风控需审批时由后端返回本次命中的审批规则 id,前端二次确认时原样透传。 var currentIgnoreRiskRuleIds = data.obj.ignoreRiskRuleIds || []; var currentTrialDataId = data.obj.TrialDataId; @@ -121,7 +123,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa }; var layerSetting = { type: 2, - title: "提示", + title: "风控试算详情", shadeClose: false, shade: 0.4, area: ['800px', '500px'], @@ -148,7 +150,7 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa layer.close(layerIndex); } }; - if (data.obj.type === "LackOfMoney") { + if (data.obj.type === "LackOfMoney" || isOldNewRiskConfirm) { layerSetting.btn = [page.buttonStr, '取消']; } else if (isRiskWarningConfirm) {