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) {