diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs
index 3bbea3ad..ad2b5f12 100644
--- a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs
@@ -18,7 +18,8 @@ namespace YLErp.Modules.SwapModule
[TestClass]
public class GLMS20260701DbDiagnoseTest
{
- private const string TradeNumber = "GLMS-20260701-0008";
+ private const string TradeNumber_0008 = "GLMS-20260701-0008";
+ private const string TradeNumber_0013 = "GLMS-20260701-0013";
#region 1) 录真实数据快照(手动跑)
@@ -31,8 +32,8 @@ namespace YLErp.Modules.SwapModule
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
- var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
- Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认环境");
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008);
+ Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境");
var snapshot = new JObject
{
@@ -99,13 +100,25 @@ namespace YLErp.Modules.SwapModule
[TestMethod]
[TestCategory("DbDiagnose")]
public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult()
+ {
+ DiagnoseTrade(TradeNumber_0008);
+ }
+
+ [TestMethod]
+ [TestCategory("DbDiagnose")]
+ public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult()
+ {
+ DiagnoseTrade(TradeNumber_0013);
+ }
+
+ private void DiagnoseTrade(string tradeNumber)
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
- var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
- if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; }
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; }
// 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减)
var marginPositions = db.swap_position
@@ -127,11 +140,11 @@ namespace YLErp.Modules.SwapModule
.ToList();
Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) ==============");
- Console.WriteLine($" {"Id",-8}{"Mode",-6}{"Dir",-6}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
+ Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}");
foreach (var p in allPositions)
{
var ul = p.UnderlyingCode ?? "";
- Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-6}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}");
+ Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}");
}
// 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异
@@ -143,6 +156,14 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}");
Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值");
+ // 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发)
+ Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)==============");
+ foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6))
+ {
+ var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id);
+ Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}");
+ }
+
// 2.2 EOD 持仓 InterestPrincipalFix 逐日序列
var eodMarginSeq = db.eod_swap_position
.Where(e => e.SwapTradeId == td.id && !e.Invalid
@@ -171,12 +192,38 @@ namespace YLErp.Modules.SwapModule
Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}");
}
+ // 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值
+ var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions);
+ Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 ==============");
+ foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6))
+ {
+ Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)");
+ }
+
+ // 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue
+ // controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests
+ // 等价于 HTTP POST /swaptrade2/GetUnwindInterestList
+ Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)==============");
+ decimal frontClosePercent = 0.7m;
+ decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金
+ decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金
+ Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}");
+ decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue);
+ Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)");
+ var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest));
+ var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.平仓);
+ Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:");
+ foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金))
+ {
+ Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}");
+ }
+
// 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值
try
{
var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest);
- var svc = new SwapDealService(user);
- var interests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓);
+ var svcFull = new SwapDealService(user);
+ var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓);
Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 ==============");
foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6))
diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs
new file mode 100644
index 00000000..dfc268b9
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs
@@ -0,0 +1,103 @@
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// InitUnwind 默认 ClosePercent 计算的回归测试。
+ /// ---------------------------------------------------------------
+ /// 守卫提交 e2fb456b "fix 平仓(InitUnwind L267)硬编码 1 而不是剩余平仓比例"。
+ ///
+ /// 旧 bug:InitUnwind 默认把 ClosePercent 硬编码为 1(按"占剩余 100%"),
+ /// 但前端约定 ClosePercent 是"占期初(original)"口径(A),1 表示平掉原始本金的 100%。
+ /// 多次部分平仓后剩余本金 < 期初本金,此时默认 1 在前端语义上意味着"还要平掉原始全部",
+ /// 与"平掉剩余全部"意图不符,且会触发后端 ToRemainingClosePercent 转换后 >1 被 cap 到 1,
+ /// 表面看无差异但语义混乱,且若前端 / 事件展示直接用此值会出错。
+ ///
+ /// 修复:ClosePercent = PosiNotionalValue / NotionalValue(占期初口径的"平剩余全部")。
+ /// 抽出为纯函数 CalcDefaultInitClosePercent 以支持无库单测。
+ ///
+ [TestClass]
+ public class InitUnwindDefaultClosePercentTest
+ {
+ // ================================================================
+ // 场景1:未平仓 PosiNotionalValue == NotionalValue → ClosePercent = 1
+ // ================================================================
+ [TestMethod]
+ public void 未平仓_剩余等于期初_默认ClosePercent为1()
+ {
+ var result = SwapDealService.CalcDefaultInitClosePercent(
+ notionalValue: 1_000_000m, posiNotionalValue: 1_000_000m);
+
+ Assert.AreEqual(1m, result, "未平仓:默认应平 100%(占期初)");
+ }
+
+ // ================================================================
+ // 场景2:已平 30%(剩 70%)→ ClosePercent = 0.7
+ // ================================================================
+ [TestMethod]
+ public void 已平30_剩70_默认ClosePercent为0_7()
+ {
+ var result = SwapDealService.CalcDefaultInitClosePercent(
+ notionalValue: 1_000_000m, posiNotionalValue: 700_000m);
+
+ Assert.AreEqual(0.7m, result, 0.0001m, "已平 30% 剩 70%:默认 ClosePercent=0.7(占期初)");
+ }
+
+ // ================================================================
+ // 场景3:除零保护 NotionalValue = 0 → 返回 1(容错)
+ // ================================================================
+ [TestMethod]
+ public void 期初名义本金为零_返回1_容错不除零()
+ {
+ var result = SwapDealService.CalcDefaultInitClosePercent(
+ notionalValue: 0m, posiNotionalValue: 100_000m);
+
+ Assert.AreEqual(1m, result, "期初本金为 0 时容错返回 1,不应抛除零异常");
+ }
+
+ // ================================================================
+ // 场景4:GLMS-20260701-0013 真实快照(已平 2 次)
+ // 期初 NotionalValue = 980,000 / 剩余 PosiNotionalValue = 686,000.07
+ // 期望 ClosePercent ≈ 0.7(686000.07/980000)
+ // ================================================================
+ [TestMethod]
+ public void GLMS20260701_0013_已平两次_默认ClosePercent约为0_7()
+ {
+ var result = SwapDealService.CalcDefaultInitClosePercent(
+ notionalValue: 980_000m, posiNotionalValue: 686_000.07m);
+
+ // 686000.07 / 980000 = 0.700000071...
+ Assert.AreEqual(0.7m, result, 0.0001m,
+ "GLMS-20260701-0013 已平两次:默认 ClosePercent 应≈0.7(占期初),旧 bug 会硬编码 1");
+ }
+
+ // ================================================================
+ // 场景5:与 ToRemainingClosePercent 联动验证
+ // 前端拿 InitUnwind 返回的 A(占期初) 默认值,经 ToRemainingClosePercent 转 B(占剩余),
+ // 应恰好 = 1.0(因为"平剩余全部"在占剩余语义下就是 100%)
+ // ================================================================
+ [TestMethod]
+ public void 默认A经ToRemainingClosePercent转B应为1_平剩余全部()
+ {
+ const decimal notionalValue = 1_000_000m;
+ const decimal posiNotionalValue = 600_000m; // 已平 40%,剩 60%
+
+ var defaultA = SwapDealService.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue);
+ var convertedB = SwapDealService.ToRemainingClosePercent(defaultA, notionalValue, posiNotionalValue);
+
+ Assert.AreEqual(0.6m, defaultA, 0.0001m, "占期初默认 A=0.6");
+ Assert.AreEqual(1.0m, convertedB, 0.0001m,
+ "A=0.6 经 ToRemainingClosePercent 转换 → B=1.0(占剩余 100% = 平剩余全部),此为占期初/占剩余双语义自洽的关键不变式");
+ }
+
+ // ================================================================
+ // 场景6:全平完(PosiNotionalValue=0)→ ClosePercent=0(边界,实际不会进 InitUnwind)
+ // ================================================================
+ [TestMethod]
+ public void 全平完剩余为零_ClosePercent为零()
+ {
+ var result = SwapDealService.CalcDefaultInitClosePercent(
+ notionalValue: 1_000_000m, posiNotionalValue: 0m);
+
+ Assert.AreEqual(0m, result, "剩余本金为 0 时 ClosePercent=0(边界场景,实际全部平完不会再进 InitUnwind)");
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs
new file mode 100644
index 00000000..4be39977
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs
@@ -0,0 +1,206 @@
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。
+ /// ---------------------------------------------------------------
+ /// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。
+ ///
+ /// 旧 bug:eod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向,
+ /// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本),
+ /// 框架合约已实现收益虚高。
+ ///
+ /// 修复:新增 CalculateSwapRealizedPnl ——
+ /// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向)
+ /// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向
+ /// 最终:RealizedInterest × interestRatio + 其他 4 字段
+ ///
+ /// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
+ /// 本测试直接锁定方向反向契约,防止后续误改回归。
+ ///
+ [TestClass]
+ public class SwapEodRealizedPnlCalcTest
+ {
+ // ================================================================
+ // 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向)
+ // ================================================================
+ [TestMethod]
+ public void 非保证金腿_收取方向_利息维持原向系数为1()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.固定值,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedInterest: 1000m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ Assert.AreEqual(1000m, result, 0.0001m,
+ "非保证金腿收取方向:利息 ×(+1)=1000");
+ }
+
+ // ================================================================
+ // 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向)
+ // ================================================================
+ [TestMethod]
+ public void 非保证金腿_支付方向_利息维持原向系数为负1()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.固定值,
+ interestDirection: (int)SwapDirectionEnum.支付,
+ realizedInterest: 1000m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ Assert.AreEqual(-1000m, result, 0.0001m,
+ "非保证金腿支付方向:利息 ×(-1)=-1000");
+ }
+
+ // ================================================================
+ // 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1
+ // 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本
+ // ================================================================
+ [TestMethod]
+ public void 保证金腿_初始预付金_收取方向_利息反向系数为负1()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.初始预付金,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedInterest: 1000m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ Assert.AreEqual(-1000m, result, 0.0001m,
+ "保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000)");
+ }
+
+ // ================================================================
+ // 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1
+ // ================================================================
+ [TestMethod]
+ public void 保证金腿_初始预付金_支付方向_利息反向系数为1()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.初始预付金,
+ interestDirection: (int)SwapDirectionEnum.支付,
+ realizedInterest: 1000m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ Assert.AreEqual(1000m, result, 0.0001m,
+ "保证金腿支付方向:利息应反向 ×(+1)=1000");
+ }
+
+ // ================================================================
+ // 场景5:追加预付金同初始预付金,同样走反向逻辑
+ // ================================================================
+ [TestMethod]
+ public void 保证金腿_追加预付金_收取方向_利息反向()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.追加预付金,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedInterest: 500m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ Assert.AreEqual(-500m, result, 0.0001m,
+ "追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑");
+ }
+
+ // ================================================================
+ // 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee)
+ // 保证金腿收取方向,Interest=200, 其他各 100
+ // 期望:100 + 100 + 100 + 200×(-1) + 100 = 200
+ // ================================================================
+ [TestMethod]
+ public void 完整5字段汇总_保证金腿收取方向_利息反向后合计正确()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.初始预付金,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedMtmPnL: 100m,
+ realizedDividend: 100m,
+ realizedFee: 100m,
+ realizedInterest: 200m,
+ realizedInterestFee: 100m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ // 100 + 100 + 100 + 200×(-1) + 100 = 200
+ Assert.AreEqual(200m, result, 0.0001m,
+ "5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算");
+ }
+
+ // ================================================================
+ // 场景7:完整 5 字段汇总(非保证金腿收取方向)
+ // 非保证金腿收取方向,Interest=200, 其他各 100
+ // 期望:100 + 100 + 100 + 200×(+1) + 100 = 600
+ // ================================================================
+ [TestMethod]
+ public void 完整5字段汇总_非保证金腿收取方向_利息原向合计正确()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.固定值,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedMtmPnL: 100m,
+ realizedDividend: 100m,
+ realizedFee: 100m,
+ realizedInterest: 200m,
+ realizedInterestFee: 100m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ // 100 + 100 + 100 + 200×(+1) + 100 = 600
+ Assert.AreEqual(600m, result, 0.0001m,
+ "5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600");
+ }
+
+ // ================================================================
+ // 场景8:RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和
+ // ================================================================
+ [TestMethod]
+ public void 利息为零_方向反向无影响_结果为其他4字段之和()
+ {
+ var pos = NewPosition(
+ interestMode: (int)InterestModeEnum.初始预付金,
+ interestDirection: (int)SwapDirectionEnum.收取,
+ realizedMtmPnL: 100m,
+ realizedDividend: 50m,
+ realizedFee: 30m,
+ realizedInterest: 0m,
+ realizedInterestFee: 20m);
+
+ var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos);
+
+ // 100 + 50 + 30 + 0×(-1) + 20 = 200
+ Assert.AreEqual(200m, result, 0.0001m,
+ "RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和");
+ }
+
+ // ================================================================
+ // Helper:构造 eod_swap_position(只设置参与计算的 7 个字段)
+ // ================================================================
+ private static eod_swap_position NewPosition(
+ int interestMode,
+ int interestDirection,
+ decimal realizedMtmPnL = 0m,
+ decimal realizedDividend = 0m,
+ decimal realizedFee = 0m,
+ decimal realizedInterest = 0m,
+ decimal realizedInterestFee = 0m)
+ {
+ return new eod_swap_position
+ {
+ InterestMode = interestMode,
+ InterestDirection = interestDirection,
+ RealizedMtmPnL = realizedMtmPnL,
+ RealizedDividend = realizedDividend,
+ RealizedFee = realizedFee,
+ RealizedInterest = realizedInterest,
+ RealizedInterestFee = realizedInterestFee
+ };
+ }
+ }
+}
diff --git a/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs
new file mode 100644
index 00000000..9e8f364d
--- /dev/null
+++ b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs
@@ -0,0 +1,133 @@
+using Newtonsoft.Json;
+using YLErp.DBModels;
+using YLErp.Modules.TradeModule;
+
+namespace YLErp.Modules.TradeModule
+{
+ ///
+ /// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。
+ /// ---------------------------------------------------------------
+ /// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。
+ ///
+ /// 旧 bug:BuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金
+ /// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同,
+ /// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。
+ ///
+ /// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值;
+ /// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。
+ ///
+ /// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常
+ /// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。
+ ///
+ [TestClass]
+ public class TradeServiceBaseCloseNotionalCalcTest
+ {
+ // ================================================================
+ // 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义
+ // ================================================================
+
+ [TestMethod]
+ public void 互换_EventData为null_返回0_不抛异常()
+ {
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null);
+ Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0");
+ }
+
+ [TestMethod]
+ public void 互换_EventData为空字符串_返回0_不抛异常()
+ {
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("");
+ Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0");
+ }
+
+ [TestMethod]
+ public void 互换_EventData为非法JSON_返回0_不抛异常()
+ {
+ // 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json");
+ Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常");
+ }
+
+ [TestMethod]
+ public void 互换_EventData为合法JSON_正数CloseNotionalValue_原值返回()
+ {
+ var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m });
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
+ Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回");
+ }
+
+ [TestMethod]
+ public void 互换_EventData为合法JSON_负数CloseNotionalValue_取绝对值()
+ {
+ // 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负
+ var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m });
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
+ Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000");
+ }
+
+ [TestMethod]
+ public void 互换_EventData为合法JSON_CloseNotionalValue为零_返回0()
+ {
+ var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m });
+ var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData);
+ Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0");
+ }
+
+ // ================================================================
+ // 二、CalcOptionCloseNotional 容错与绝对值语义
+ // ================================================================
+
+ [TestMethod]
+ public void 期权_两者都为null_返回0()
+ {
+ var result = TradeServiceBase.CalcOptionCloseNotional(null, null);
+ Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0");
+ }
+
+ [TestMethod]
+ public void 期权_期初名义本金为null_返回0()
+ {
+ var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d);
+ Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0");
+ }
+
+ [TestMethod]
+ public void 期权_平仓比例为null_返回0()
+ {
+ var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null);
+ Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0");
+ }
+
+ [TestMethod]
+ public void 期权_两者都有值_正数相乘_返回乘积()
+ {
+ // 1,000,000 × 0.3 = 300,000
+ var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d);
+ Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K");
+ }
+
+ [TestMethod]
+ public void 期权_期初名义本金为负数_取绝对值后相乘()
+ {
+ // 异常但容错:-1,000,000 × 0.3 → Abs → 300,000
+ var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d);
+ Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘");
+ }
+
+ [TestMethod]
+ public void 期权_平仓比例为负数_取绝对值后相乘()
+ {
+ // 异常但容错:1,000,000 × -0.3 → Abs → 300,000
+ var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d);
+ Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘");
+ }
+
+ [TestMethod]
+ public void 期权_两者都为负数_取绝对值后相乘()
+ {
+ // -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致)
+ var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d);
+ Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘");
+ }
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs
new file mode 100644
index 00000000..cb2e2fcd
--- /dev/null
+++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+
+namespace YLErp.Modules.RiskEngine.Dto
+{
+ ///
+ /// 批量停用规则结果
+ ///
+ public class BatchDisableRulesResult
+ {
+ /// 是否全部成功
+ public bool Success { get; set; }
+ /// 总数量
+ public int TotalCount { get; set; }
+ /// 成功数量
+ public int SuccessCount { get; set; }
+ /// 错误信息
+ public string ErrorMessage { get; set; }
+ /// 级联停用的应用 ID 列表
+ public List CascadedApplicationIds { get; set; } = new List();
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs
new file mode 100644
index 00000000..58650c8f
--- /dev/null
+++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs
@@ -0,0 +1,10 @@
+namespace YLErp.Modules.RiskEngine.Dto
+{
+ ///
+ /// 批量操作请求(兼容前端 {ids: [...]} 格式)
+ ///
+ public class BatchIdsReq
+ {
+ public List Ids { get; set; }
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs
new file mode 100644
index 00000000..e5702b2e
--- /dev/null
+++ b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+
+namespace YLErp.Modules.RiskEngine.Dto
+{
+ ///
+ /// 停用规则结果
+ ///
+ public class DisableRuleResult
+ {
+ /// 级联停用的应用 ID 列表
+ public List CascadedApplicationIds { get; set; } = new List();
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs
index d2d98568..e04c6ddc 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs
@@ -1,3 +1,5 @@
+using YLErp.DBModels;
+
namespace YLErp.Modules.RiskEngine.Dto
{
///
@@ -11,5 +13,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public string RuleName { get; set; }
/// 规则描述文本
public string RuleText { get; set; }
+ /// 规则状态
+ public RiskRuleStatus Status { get; set; }
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs
index 45d4432d..471f302f 100644
--- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs
+++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs
@@ -1,4 +1,4 @@
-using Newtonsoft.Json;
+using Newtonsoft.Json;
using Qdp.Foundation.Utilities;
using System.Reflection;
using YLErp.BLL;
@@ -918,6 +918,17 @@ namespace YLErp.Modules.RiskEngine
return true;
}
+ var hasAnyScope =
+ !IsScopeEmpty(application.ScopeAssetBookIds) ||
+ !IsScopeEmpty(application.ScopeClientIds) ||
+ !IsScopeEmpty(application.ScopeUnderlyingTypes) ||
+ !IsScopeEmpty(application.ScopeTradeTypes);
+ if (!hasAnyScope)
+ {
+ _logger.Error($"[风控引擎] 非全局应用未配置任何适用范围,拒绝匹配 - ApplicationId: {application.Id}");
+ return false;
+ }
+
if (trade == null)
{
return false;
diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
index be59ce93..d12d3be2 100644
--- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
+++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
@@ -1,9 +1,10 @@
-using BaseOUDAL;
+using BaseOUDAL;
using Newtonsoft.Json;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using YLErp.BLL;
+using YLErp.Commons;
using YLErp.DBModels;
using YLErp.Modules.RiskEngine.Dto;
@@ -76,9 +77,11 @@ using YLErp.Modules.RiskEngine.Dto;
UpdateRule(long, UpdateRiskRuleReq) 修改规则(乐观锁校验,Version+1)
DeleteRule(long) 删除规则(强保护:有 Active 应用时拒绝)
EnableRule(long) 启用规则(仅 Disabled → Active)
- DisableRule(long) 停用规则(仅 Active → Disabled)
+ DisableRule(long) 停用规则(仅 Active → Disabled,级联停用关联应用)
GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号)
BatchDeleteRules(List) 批量删除规则(逐条保护检查,单事务)
+ BatchEnableRules(List) 批量启用规则(逐条编译校验)
+ BatchDisableRules(List) 批量停用规则(级联停用关联应用)
GetRuleApplications(long) 获取规则关联的应用配置列表
GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉)
@@ -88,6 +91,7 @@ using YLErp.Modules.RiskEngine.Dto;
CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验)
UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验)
DeleteApplication(long) 删除应用(软删除)
+ BatchDeleteApplications(List) 批量删除应用(软删除)
EnableApplication(long) 启用应用(校验关联规则状态)
DisableApplication(long) 停用应用
BatchEnableApplications(List) 批量启用(逐条校验关联规则)
@@ -207,6 +211,39 @@ namespace YLErp.Modules.RiskEngine
{ RiskVariableDataType.Boolean, new HashSet { "是", "否" } }
};
+ private static readonly Dictionary AuditOperationTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "RULE_CREATE", "新建规则" },
+ { "RULE_UPDATE", "修改规则" },
+ { "RULE_DELETE", "删除规则" },
+ { "RULE_ENABLE", "启用规则" },
+ { "RULE_DISABLE", "停用规则" },
+ { "RULE_BATCH_DELETE", "批量删除规则" },
+ { "RULE_BATCH_ENABLE", "批量启用规则" },
+ { "RULE_BATCH_DISABLE", "批量停用规则" },
+ { "APP_CREATE", "新建应用" },
+ { "APP_UPDATE", "修改应用" },
+ { "APP_DELETE", "删除应用" },
+ { "APP_ENABLE", "启用应用" },
+ { "APP_DISABLE", "停用应用" },
+ { "APP_BATCH_ENABLE", "批量启用应用" },
+ { "APP_BATCH_DISABLE", "批量停用应用" },
+ { "APP_BATCH_DELETE", "批量删除应用" },
+ { "RULE_CASCADE_DISABLE_APP", "规则级联停用应用" },
+ { "VAR_CREATE", "新增变量" },
+ { "VARIABLE_CREATE", "新增变量" },
+ { "VAR_UPDATE", "修改变量" },
+ { "VAR_DELETE", "删除变量" },
+ { "VAR_CASCADE_UPDATE_RULE", "变量级联更新规则" }
+ };
+
+ private static readonly Dictionary AuditTargetTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "RULE", "规则" },
+ { "APPLICATION", "应用" },
+ { "VARIABLE", "变量" }
+ };
+
public RiskRuleService(OptUserInfo userInfo) : base(userInfo)
{
}
@@ -221,6 +258,39 @@ namespace YLErp.Modules.RiskEngine
#region Private Helpers
+ private static string GetAuditOperationTypeName(string operationType)
+ {
+ if (string.IsNullOrWhiteSpace(operationType))
+ return string.Empty;
+
+ return AuditOperationTypeNames.TryGetValue(operationType.Trim(), out var name)
+ ? name
+ : operationType;
+ }
+
+ private static string GetAuditTargetTypeName(string targetType)
+ {
+ if (string.IsNullOrWhiteSpace(targetType))
+ return string.Empty;
+
+ return AuditTargetTypeNames.TryGetValue(targetType.Trim(), out var name)
+ ? name
+ : targetType;
+ }
+
+ private static string FormatAuditId(object value)
+ {
+ return value == null ? string.Empty : value.ToString();
+ }
+
+ private static string SanitizeFileNamePart(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var sanitized = Regex.Replace(value.Trim(), @"[\\/:*?""<>|\r\n]+", "_");
+ return sanitized.Length <= 40 ? sanitized : sanitized.Substring(0, 40);
+ }
///
/// 写入审计日志
///
@@ -398,24 +468,32 @@ namespace YLErp.Modules.RiskEngine
///
private void ValidateScopeFields(CreateRiskApplicationReq req)
{
+ var hasAnyScope =
+ !string.IsNullOrWhiteSpace(req.ScopeAssetBookIds) ||
+ !string.IsNullOrWhiteSpace(req.ScopeClientIds) ||
+ !string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes) ||
+ !string.IsNullOrWhiteSpace(req.ScopeTradeTypes);
+
if (req.ScopeIsGlobal == 1)
{
- if (!string.IsNullOrEmpty(req.ScopeAssetBookIds) ||
- !string.IsNullOrEmpty(req.ScopeClientIds) ||
- !string.IsNullOrEmpty(req.ScopeUnderlyingTypes) ||
- !string.IsNullOrEmpty(req.ScopeTradeTypes))
+ if (hasAnyScope)
{
throw new ServiceException("全局适用时,不应指定其他适用范围维度");
}
return;
}
- if (!string.IsNullOrEmpty(req.ScopeAssetBookIds))
+ if (!hasAnyScope)
+ {
+ throw new ServiceException("非全局应用至少需要指定一个适用范围");
+ }
+
+ if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds))
{
ValidateIdList(req.ScopeAssetBookIds, "适用账户");
}
- if (!string.IsNullOrEmpty(req.ScopeClientIds))
+ if (!string.IsNullOrWhiteSpace(req.ScopeClientIds))
{
ValidateIdList(req.ScopeClientIds, "适用对手方");
}
@@ -540,6 +618,27 @@ namespace YLErp.Modules.RiskEngine
return ids;
}
+ ///
+ /// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。
+ ///
+ private string NormalizeRuleIds(string ruleIds)
+ {
+ return string.Join(",", ParseRuleIds(ruleIds).Distinct());
+ }
+
+ ///
+ /// EF Core 可翻译的逗号分隔字段精确匹配表达式
+ ///
+ private static Expression> RuleIdsMatchExpr(long ruleId)
+ {
+ var idStr = ruleId.ToString();
+ return a => a.RuleIds != null &&
+ (a.RuleIds == idStr ||
+ a.RuleIds.StartsWith(idStr + ",") ||
+ a.RuleIds.EndsWith("," + idStr) ||
+ a.RuleIds.Contains("," + idStr + ","));
+ }
+
///
/// 校验规则状态可用性并确保编译缓存就绪。
/// 返回不可用规则信息列表,列表为空表示全部通过。
@@ -722,7 +821,12 @@ namespace YLErp.Modules.RiskEngine
OptDate = r.OptDate.GetValueOrDefault(),
UpdateOptName = r.UpdateOptName,
UpdateDate = r.UpdateDate ?? r.OptDate.GetValueOrDefault(),
- ApplicationCount = DbContext.glms_risk_rule_application.Count(a => a.RuleIds.Contains(r.id.ToString()) && a.Status != RiskRuleStatus.Deleted)
+ ApplicationCount = DbContext.glms_risk_rule_application
+ .Count(a => a.RuleIds != null &&
+ (a.RuleIds == r.id.ToString() ||
+ a.RuleIds.StartsWith(r.id.ToString() + ",") ||
+ a.RuleIds.EndsWith("," + r.id.ToString()) ||
+ a.RuleIds.Contains("," + r.id.ToString() + ",")))
});
return result.ToSearchList(req);
@@ -735,9 +839,9 @@ namespace YLErp.Modules.RiskEngine
{
var rule = GetRuleOrThrow(ruleId);
- var ruleIdStr = ruleId.ToString();
+
var applications = DbContext.glms_risk_rule_application
- .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted)
+ .Where(RuleIdsMatchExpr(ruleId))
.Select(a => new RiskRuleApplicationSummary
{
Id = a.id,
@@ -858,9 +962,10 @@ namespace YLErp.Modules.RiskEngine
{
var rule = GetRuleOrThrow(ruleId);
- var ruleIdStr = ruleId.ToString();
+
var activeCount = DbContext.glms_risk_rule_application
- .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active);
+ .Where(a => a.Status == RiskRuleStatus.Active)
+ .Count(RuleIdsMatchExpr(ruleId));
if (activeCount > 0)
throw new ServiceException($"该规则仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用");
@@ -903,7 +1008,7 @@ namespace YLErp.Modules.RiskEngine
///
/// 停用规则(仅 Active → Disabled)
///
- public void DisableRule(long ruleId)
+ public DisableRuleResult DisableRule(long ruleId)
{
var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Active);
if (rule == null)
@@ -915,9 +1020,38 @@ namespace YLErp.Modules.RiskEngine
rule.UpdateDate = DateTime.Now;
WriteAuditLog("RULE_DISABLE", "RULE", ruleId, rule.RuleName, "停用规则");
+
+ // 级联停用关联的 Active 应用配置
+ var activeApps = DbContext.glms_risk_rule_application
+ .Where(a => a.Status == RiskRuleStatus.Active)
+ .Where(RuleIdsMatchExpr(ruleId))
+ .ToList();
+
+ var cascadedIds = new List();
+ foreach (var app in activeApps)
+ {
+ app.Status = RiskRuleStatus.Disabled;
+ app.UpdateOptId = UserId;
+ app.UpdateOptName = UserName;
+ app.UpdateDate = DateTime.Now;
+ cascadedIds.Add(app.id);
+
+ WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}",
+ $"因规则'{rule.RuleName}'(ID:{ruleId})停用,应用配置被级联停用");
+ }
+
+ if (cascadedIds.Any())
+ {
+ _logger.Info($"[停用规则] 规则ID: {ruleId}, 级联停用应用: {string.Join(",", cascadedIds)}");
+ }
+
DbContext.SaveChanges();
RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId);
+ if (cascadedIds.Any())
+ RiskEngineService.GetInstance().RefreshApplication();
+
+ return new DisableRuleResult { CascadedApplicationIds = cascadedIds };
}
///
@@ -965,24 +1099,58 @@ namespace YLErp.Modules.RiskEngine
///
public BatchOperationResult BatchDeleteRules(List ruleIds)
{
+ if (ruleIds == null || ruleIds.Count == 0)
+ throw new ServiceException("规则ID列表不能为空");
+
+ _logger.Info($"[批量删除] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}");
+
var rules = DbContext.glms_risk_rule
.Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted)
.ToList();
- if (rules.Count != ruleIds.Count)
- throw new ServiceException("部分规则不存在或已删除");
+ _logger.Info($"[批量删除] DB查询完成 - 匹配到 {rules.Count} 条规则");
+ var foundIds = rules.Select(r => (long)r.id).ToList();
+ var missingIds = ruleIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = ruleIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}"
+ };
+ }
+
+ var blockedRules = new List();
foreach (var rule in rules)
{
- var ruleIdStr = rule.id.ToString();
- var activeCount = DbContext.glms_risk_rule_application
- .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active);
+ _logger.Info($"[批量删除] 检查关联应用 - 规则ID: {rule.id}");
+ var activeApps = DbContext.glms_risk_rule_application
+ .Where(a => a.Status == RiskRuleStatus.Active)
+ .Where(RuleIdsMatchExpr(rule.id))
+ .Select(a => (long)a.id)
+ .ToList();
+ var activeCount = activeApps.Count;
+ _logger.Info($"[批量删除] 关联应用检查结果 - 规则ID: {rule.id}, ActiveCount: {activeCount}, AppIds: [{string.Join(",", activeApps)}]");
if (activeCount > 0)
- throw new ServiceException($"规则 '{rule.RuleName}' 仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用");
+ blockedRules.Add($"规则 '{rule.RuleName}'(ID:{rule.id}) 仍有 {activeCount} 个生效中的应用配置(IDs: {string.Join(",", activeApps)})");
+ }
+ if (blockedRules.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = ruleIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = string.Join(";", blockedRules)
+ };
}
foreach (var rule in rules)
{
+ _logger.Info($"[批量删除] 写入状态 - 规则ID: {rule.id}");
rule.Status = RiskRuleStatus.Deleted;
rule.Version = rule.Version + 1;
rule.UpdateOptId = UserId;
@@ -992,11 +1160,14 @@ namespace YLErp.Modules.RiskEngine
WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则");
}
+ _logger.Info("[批量删除] 保存数据库...");
DbContext.SaveChanges();
+ _logger.Info("[批量删除] 刷新规则缓存...");
foreach (var ruleId in ruleIds)
{
RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId);
}
+ _logger.Info("[批量删除] 完成");
return new BatchOperationResult
{
@@ -1006,6 +1177,210 @@ namespace YLErp.Modules.RiskEngine
};
}
+ ///
+ /// 批量启用规则(仅 Disabled → Active,逐条编译校验)
+ ///
+ public BatchOperationResult BatchEnableRules(List ruleIds)
+ {
+ if (ruleIds == null || ruleIds.Count == 0)
+ throw new ServiceException("规则ID列表不能为空");
+
+ _logger.Info($"[批量启用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}");
+
+ var rules = DbContext.glms_risk_rule
+ .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted)
+ .ToList();
+
+ _logger.Info($"[批量启用规则] DB查询完成 - 匹配到 {rules.Count} 条规则");
+
+ var foundIds = rules.Select(r => (long)r.id).ToList();
+ var missingIds = ruleIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = ruleIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}"
+ };
+ }
+
+ // 跳过已启用的规则
+ var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Active).Select(r => (long)r.id).ToList();
+ if (skippedIds.Any())
+ _logger.Info($"[批量启用规则] 跳过已启用规则 - IDs: {string.Join(",", skippedIds)}");
+
+ rules = rules.Where(r => r.Status != RiskRuleStatus.Active).ToList();
+ if (!rules.Any())
+ {
+ _logger.Info("[批量启用规则] 所有规则均已启用,无需操作");
+ return new BatchOperationResult
+ {
+ Success = true,
+ TotalCount = ruleIds.Count,
+ SuccessCount = ruleIds.Count
+ };
+ }
+
+ // 逐条编译校验,任一失败则整体拒绝
+ var errors = new List();
+ foreach (var rule in rules)
+ {
+ var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr);
+ if (!compileResult.Success)
+ errors.Add($"规则'{rule.RuleName}'(ID:{rule.id}):{compileResult.ErrorMessage}");
+ }
+ if (errors.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = ruleIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"规则表达式编译失败:{string.Join(";", errors)}"
+ };
+ }
+
+ foreach (var rule in rules)
+ {
+ _logger.Info($"[批量启用规则] 写入状态 - 规则ID: {rule.id}");
+ rule.Status = RiskRuleStatus.Active;
+ rule.UpdateOptId = UserId;
+ rule.UpdateOptName = UserName;
+ rule.UpdateDate = DateTime.Now;
+ }
+
+ var enabledIds = rules.Select(r => r.id).ToList();
+ WriteAuditLog("RULE_BATCH_ENABLE", "RULE", 0, "",
+ $"批量启用规则(IDs: {string.Join(",", enabledIds)})");
+
+ _logger.Info("[批量启用规则] 保存数据库...");
+ DbContext.SaveChanges();
+ _logger.Info("[批量启用规则] 刷新规则缓存...");
+ foreach (var rule in rules)
+ {
+ RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id);
+ }
+ _logger.Info("[批量启用规则] 完成");
+
+ return new BatchOperationResult
+ {
+ Success = true,
+ TotalCount = ruleIds.Count,
+ SuccessCount = ruleIds.Count
+ };
+ }
+
+ ///
+ /// 批量停用规则(仅 Active → Disabled,级联停用关联应用)
+ ///
+ public BatchDisableRulesResult BatchDisableRules(List ruleIds)
+ {
+ if (ruleIds == null || ruleIds.Count == 0)
+ throw new ServiceException("规则ID列表不能为空");
+
+ _logger.Info($"[批量停用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}");
+
+ var rules = DbContext.glms_risk_rule
+ .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted)
+ .ToList();
+
+ _logger.Info($"[批量停用规则] DB查询完成 - 匹配到 {rules.Count} 条规则");
+
+ var foundIds = rules.Select(r => (long)r.id).ToList();
+ var missingIds = ruleIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchDisableRulesResult
+ {
+ Success = false,
+ TotalCount = ruleIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}"
+ };
+ }
+
+ // 跳过已停用的规则
+ var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Disabled).Select(r => (long)r.id).ToList();
+ if (skippedIds.Any())
+ _logger.Info($"[批量停用规则] 跳过已停用规则 - IDs: {string.Join(",", skippedIds)}");
+
+ rules = rules.Where(r => r.Status != RiskRuleStatus.Disabled).ToList();
+ if (!rules.Any())
+ {
+ _logger.Info("[批量停用规则] 所有规则均已停用,无需操作");
+ return new BatchDisableRulesResult
+ {
+ Success = true,
+ TotalCount = ruleIds.Count,
+ SuccessCount = ruleIds.Count
+ };
+ }
+
+ var allCascadedAppIds = new List();
+ foreach (var rule in rules)
+ {
+ _logger.Info($"[批量停用规则] 写入状态 - 规则ID: {rule.id}");
+ rule.Status = RiskRuleStatus.Disabled;
+ rule.UpdateOptId = UserId;
+ rule.UpdateOptName = UserName;
+ rule.UpdateDate = DateTime.Now;
+
+ // 级联停用关联的 Active 应用配置
+ var activeApps = DbContext.glms_risk_rule_application
+ .Where(a => a.Status == RiskRuleStatus.Active)
+ .Where(RuleIdsMatchExpr(rule.id))
+ .ToList();
+
+ var cascadedIds = new List();
+ foreach (var app in activeApps)
+ {
+ // 同一应用可能同时关联多条待停用规则,只对实际仍为 Active 的应用处理一次。
+ if (app.Status != RiskRuleStatus.Active)
+ continue;
+
+ app.Status = RiskRuleStatus.Disabled;
+ app.UpdateOptId = UserId;
+ app.UpdateOptName = UserName;
+ app.UpdateDate = DateTime.Now;
+ cascadedIds.Add(app.id);
+
+ WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}",
+ $"因规则'{rule.RuleName}'(ID:{rule.id})停用,应用配置被级联停用");
+ }
+
+ if (cascadedIds.Any())
+ {
+ _logger.Info($"[批量停用规则] 规则ID: {rule.id}, 级联停用应用: {string.Join(",", cascadedIds)}");
+ allCascadedAppIds.AddRange(cascadedIds);
+ }
+ }
+
+ var disabledIds = rules.Select(r => r.id).ToList();
+ WriteAuditLog("RULE_BATCH_DISABLE", "RULE", 0, "",
+ $"批量停用规则(IDs: {string.Join(",", disabledIds)})");
+
+ _logger.Info("[批量停用规则] 保存数据库...");
+ DbContext.SaveChanges();
+ _logger.Info("[批量停用规则] 刷新规则缓存...");
+ foreach (var rule in rules)
+ {
+ RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id);
+ }
+ if (allCascadedAppIds.Any())
+ RiskEngineService.GetInstance().RefreshApplication();
+ _logger.Info("[批量停用规则] 完成");
+
+ return new BatchDisableRulesResult
+ {
+ Success = true,
+ TotalCount = ruleIds.Count,
+ SuccessCount = ruleIds.Count,
+ CascadedApplicationIds = allCascadedAppIds
+ };
+ }
+
///
/// 获取规则关联的应用配置列表
///
@@ -1013,9 +1388,9 @@ namespace YLErp.Modules.RiskEngine
{
GetRuleOrThrow(ruleId);
- var ruleIdStr = ruleId.ToString();
var apps = DbContext.glms_risk_rule_application
- .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted)
+ .Where(a => a.Status != RiskRuleStatus.Deleted)
+ .Where(RuleIdsMatchExpr(ruleId))
.OrderByDescending(a => a.UpdateDate)
.ToList();
@@ -1046,18 +1421,19 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 获取所有启用规则列表(轻量字段,供下拉选择)
+ /// 获取所有未删除规则列表(轻量字段,供下拉选择)
///
public List GetAllRuleList()
{
return DbContext.glms_risk_rule
- .Where(r => r.Status == RiskRuleStatus.Active)
+ .Where(r => r.Status != RiskRuleStatus.Deleted)
.OrderBy(r => r.id)
.Select(r => new RiskRuleSimpleItem
{
Id = r.id,
RuleName = r.RuleName,
- RuleText = r.RuleText
+ RuleText = r.RuleText,
+ Status = r.Status
})
.ToList();
}
@@ -1093,8 +1469,12 @@ namespace YLErp.Modules.RiskEngine
{
query = query.Where(a => DbContext.glms_risk_rule
.Any(r => r.Status != RiskRuleStatus.Deleted
- && a.RuleIds.Contains(r.id.ToString())
- && r.RuleName.Contains(req.RuleName)));
+ && r.RuleName.Contains(req.RuleName)
+ && a.RuleIds != null
+ && (a.RuleIds == r.id.ToString()
+ || a.RuleIds.StartsWith(r.id.ToString() + ",")
+ || a.RuleIds.EndsWith("," + r.id.ToString())
+ || a.RuleIds.Contains("," + r.id.ToString() + ","))));
}
if (!string.IsNullOrWhiteSpace(req.ScopeClientIds))
@@ -1170,7 +1550,8 @@ namespace YLErp.Modules.RiskEngine
///
public RiskApplicationDetail CreateApplication(CreateRiskApplicationReq req)
{
- var errors = ValidateAndEnsureRuleCache(req.RuleIds);
+ var normalizedRuleIds = NormalizeRuleIds(req.RuleIds);
+ var errors = ValidateAndEnsureRuleCache(normalizedRuleIds);
if (errors.Any())
throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}");
ValidateTriggerPoints(req.TriggerPoints);
@@ -1178,7 +1559,7 @@ namespace YLErp.Modules.RiskEngine
var entity = new glms_risk_rule_application
{
- RuleIds = req.RuleIds,
+ RuleIds = normalizedRuleIds,
Description = req.Description,
Status = RiskRuleStatus.Active,
ControlStrategy = req.ControlStrategy,
@@ -1203,7 +1584,7 @@ namespace YLErp.Modules.RiskEngine
DbContext.glms_risk_rule_application.Add(entity);
DbContext.SaveChanges();
- WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(req.RuleIds),
+ WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(entity.RuleIds),
$"创建应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}",
snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version }));
DbContext.SaveChanges();
@@ -1232,9 +1613,11 @@ namespace YLErp.Modules.RiskEngine
if (app.Version != req.Version)
throw new ServiceException("应用配置已被其他用户修改,请重新加载后再编辑");
+ string normalizedRuleIds = null;
if (!string.IsNullOrWhiteSpace(req.RuleIds))
{
- var errors = ValidateAndEnsureRuleCache(req.RuleIds);
+ normalizedRuleIds = NormalizeRuleIds(req.RuleIds);
+ var errors = ValidateAndEnsureRuleCache(normalizedRuleIds);
if (errors.Any())
throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}");
}
@@ -1250,8 +1633,8 @@ namespace YLErp.Modules.RiskEngine
};
ValidateScopeFields(scopeReq);
- if (!string.IsNullOrWhiteSpace(req.RuleIds))
- app.RuleIds = req.RuleIds;
+ if (normalizedRuleIds != null)
+ app.RuleIds = normalizedRuleIds;
app.Description = req.Description;
app.ControlStrategy = req.ControlStrategy;
app.TriggerPoints = req.TriggerPoints;
@@ -1293,6 +1676,63 @@ namespace YLErp.Modules.RiskEngine
RiskEngineService.GetInstance().RefreshApplication();
}
+ ///
+ /// 批量删除应用配置(软删除)
+ ///
+ public BatchOperationResult BatchDeleteApplications(List applicationIds)
+ {
+ if (applicationIds == null || applicationIds.Count == 0)
+ throw new ServiceException("应用配置ID列表不能为空");
+
+ _logger.Info($"[批量删除应用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}");
+
+ var apps = DbContext.glms_risk_rule_application
+ .Where(a => applicationIds.Contains(a.id) && a.Status != RiskRuleStatus.Deleted)
+ .ToList();
+
+ _logger.Info($"[批量删除应用] DB查询完成 - 匹配到 {apps.Count} 条应用");
+
+ var foundIds = apps.Select(a => (long)a.id).ToList();
+ var missingIds = applicationIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = applicationIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"以下应用配置不存在或已删除:{string.Join(", ", missingIds)}"
+ };
+ }
+
+ foreach (var app in apps)
+ {
+ _logger.Info($"[批量删除应用] 写入状态 - 应用ID: {app.id}");
+ app.Status = RiskRuleStatus.Deleted;
+ app.Version = app.Version + 1;
+ app.UpdateOptId = UserId;
+ app.UpdateOptName = UserName;
+ app.UpdateDate = DateTime.Now;
+ }
+
+ var deletedIds = apps.Select(a => a.id).ToList();
+ WriteAuditLog("APP_BATCH_DELETE", "APPLICATION", 0, "",
+ $"批量删除应用配置(IDs: {string.Join(",", deletedIds)})");
+
+ _logger.Info("[批量删除应用] 保存数据库...");
+ DbContext.SaveChanges();
+ _logger.Info("[批量删除应用] 刷新应用缓存...");
+ RiskEngineService.GetInstance().RefreshApplication();
+ _logger.Info("[批量删除应用] 完成");
+
+ return new BatchOperationResult
+ {
+ Success = true,
+ TotalCount = applicationIds.Count,
+ SuccessCount = applicationIds.Count
+ };
+ }
+
///
/// 启用应用配置(校验关联规则状态)
///
@@ -1342,16 +1782,46 @@ namespace YLErp.Modules.RiskEngine
///
public BatchOperationResult BatchEnableApplications(List applicationIds)
{
- _logger.Info($"[批量启用] 开始 - 传入应用数: {applicationIds?.Count ?? 0}, IDs: {string.Join(",", applicationIds ?? new List())}");
+ if (applicationIds == null || applicationIds.Count == 0)
+ throw new ServiceException("应用配置ID列表不能为空");
+
+ _logger.Info($"[批量启用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}");
var apps = DbContext.glms_risk_rule_application
- .Where(a => applicationIds.Contains(a.id) && a.Status == RiskRuleStatus.Disabled)
+ .Where(a => applicationIds.Contains(a.id))
.ToList();
- _logger.Info($"[批量启用] DB查询完成 - 匹配到 {apps.Count} 条已停用应用");
+ _logger.Info($"[批量启用] DB查询完成 - 匹配到 {apps.Count} 条应用");
- if (apps.Count != applicationIds.Count)
- throw new ServiceException("部分应用配置不存在或当前状态不允许启用");
+ var foundIds = apps.Select(a => (long)a.id).ToList();
+ var missingIds = applicationIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = applicationIds.Count,
+ SuccessCount = 0,
+ ErrorMessage = $"以下应用配置不存在:{string.Join(", ", missingIds)}"
+ };
+ }
+
+ // 跳过已启用的应用,只处理已停用的
+ var skippedIds = apps.Where(a => a.Status == RiskRuleStatus.Active).Select(a => (long)a.id).ToList();
+ if (skippedIds.Any())
+ _logger.Info($"[批量启用] 跳过已启用应用 - IDs: {string.Join(",", skippedIds)}");
+
+ apps = apps.Where(a => a.Status != RiskRuleStatus.Active).ToList();
+ if (!apps.Any())
+ {
+ _logger.Info("[批量启用] 所有应用均已启用,无需操作");
+ return new BatchOperationResult
+ {
+ Success = true,
+ TotalCount = applicationIds.Count,
+ SuccessCount = applicationIds.Count
+ };
+ }
var allErrors = new List();
foreach (var app in apps)
@@ -1360,7 +1830,7 @@ namespace YLErp.Modules.RiskEngine
var errors = ValidateAndEnsureRuleCache(app.RuleIds);
_logger.Info($"[批量启用] 规则校验结果 - 应用ID: {app.id}, Errors: {(errors.Any() ? string.Join(";", errors) : "无")}");
if (errors.Any())
- allErrors.Add($"应用 '{app.Description}':{string.Join(";", errors)}");
+ allErrors.Add($"应用 '{app.id}':{string.Join(";", errors)}");
}
if (allErrors.Any())
@@ -1404,7 +1874,10 @@ namespace YLErp.Modules.RiskEngine
///
public BatchOperationResult BatchDisableApplications(List applicationIds)
{
- _logger.Info($"[批量停用] 开始 - 传入应用数: {applicationIds?.Count ?? 0}, IDs: {string.Join(",", applicationIds ?? new List())}");
+ if (applicationIds == null || applicationIds.Count == 0)
+ throw new ServiceException("应用配置ID列表不能为空");
+
+ _logger.Info($"[批量停用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}");
var apps = DbContext.glms_risk_rule_application
.Where(a => applicationIds.Contains(a.id) && a.Status == RiskRuleStatus.Active)
@@ -1412,8 +1885,18 @@ namespace YLErp.Modules.RiskEngine
_logger.Info($"[批量停用] DB查询完成 - 匹配到 {apps.Count} 条已启用应用");
- if (apps.Count != applicationIds.Count)
- throw new ServiceException("部分应用配置不存在或当前状态不允许停用");
+ var foundIds = apps.Select(a => (long)a.id).ToList();
+ var missingIds = applicationIds.Except(foundIds).ToList();
+ if (missingIds.Any())
+ {
+ return new BatchOperationResult
+ {
+ Success = false,
+ TotalCount = applicationIds.Count,
+ SuccessCount = apps.Count,
+ ErrorMessage = $"以下应用配置不存在或当前状态不允许停用:{string.Join(", ", missingIds)}"
+ };
+ }
foreach (var app in apps)
{
@@ -1660,16 +2143,15 @@ namespace YLErp.Modules.RiskEngine
rule.UpdateOptId = UserId;
rule.UpdateOptName = UserName;
rule.UpdateDate = DateTime.Now;
+
+ WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName,
+ $"因变量'{variable.VariableName}'(ID:{variableId})表达式变更,规则表达式被级联更新");
}
DbContext.SaveChanges();
WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}");
- var cascadedRuleIds = affectedRules.Select(r => r.id.ToString()).ToList();
- WriteAuditLog("VAR_UPDATE_CASCADE", "VARIABLE", variableId, req.VariableName,
- $"变量 [{variable.VariableName}] VariableExpr 变更,级联更新规则 {string.Join("、", cascadedRuleIds)}");
-
DbContext.SaveChanges();
transaction.Commit();
}
@@ -1746,6 +2228,11 @@ namespace YLErp.Modules.RiskEngine
/// 查询审计日志列表(分页)
///
public SearchListResult QueryAuditLogs(QueryRiskAuditLogReq req)
+ {
+ return BuildAuditLogQuery(req).ToSearchList(req);
+ }
+
+ private IQueryable BuildAuditLogQuery(QueryRiskAuditLogReq req)
{
var query = DbContext.glms_risk_rule_audit_log.AsQueryable();
@@ -1806,7 +2293,55 @@ namespace YLErp.Modules.RiskEngine
OptDate = l.OptDate
});
- return result.ToSearchList(req);
+ return result;
+ }
+
+ ///
+ /// 根据查询条件生成审计日志导出文件名
+ ///
+ public string BuildAuditLogExportFileName(QueryRiskAuditLogReq req)
+ {
+ req ??= new QueryRiskAuditLogReq();
+ var conditions = new List();
+
+ var operationTypes = new List();
+ if (!string.IsNullOrWhiteSpace(req.OperationType))
+ operationTypes.Add(req.OperationType.Trim());
+ if (!string.IsNullOrWhiteSpace(req.OperationTypes))
+ {
+ operationTypes.AddRange(req.OperationTypes.Split(',',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+ }
+ var operationTypeNames = operationTypes
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(GetAuditOperationTypeName)
+ .ToList();
+ if (operationTypeNames.Any())
+ conditions.Add($"操作类型-{string.Join("+", operationTypeNames)}");
+
+ if (!string.IsNullOrWhiteSpace(req.TargetType))
+ conditions.Add($"目标类型-{GetAuditTargetTypeName(req.TargetType)}");
+ if (!string.IsNullOrWhiteSpace(req.TargetName))
+ conditions.Add($"目标名称-{SanitizeFileNamePart(req.TargetName)}");
+ if (!string.IsNullOrWhiteSpace(req.OptName))
+ conditions.Add($"操作人-{SanitizeFileNamePart(req.OptName)}");
+
+ if (req.StartDate.HasValue && req.EndDate.HasValue)
+ conditions.Add($"日期-{req.StartDate.Value:yyyyMMdd}至{req.EndDate.Value:yyyyMMdd}");
+ else if (req.StartDate.HasValue)
+ conditions.Add($"开始日期-{req.StartDate.Value:yyyyMMdd}");
+ else if (req.EndDate.HasValue)
+ conditions.Add($"结束日期-{req.EndDate.Value:yyyyMMdd}");
+
+ if (!string.IsNullOrWhiteSpace(req.Keyword))
+ conditions.Add($"关键词-{SanitizeFileNamePart(req.Keyword)}");
+
+ var suffix = conditions.Any() ? string.Join("_", conditions) : "全部";
+ var fileNameWithoutExtension = $"风控操作日志_{suffix}";
+ if (fileNameWithoutExtension.Length > 180)
+ fileNameWithoutExtension = fileNameWithoutExtension.Substring(0, 180);
+
+ return $"{fileNameWithoutExtension}.xlsx";
}
///
@@ -1814,7 +2349,28 @@ namespace YLErp.Modules.RiskEngine
///
public byte[] ExportAuditLogs(QueryRiskAuditLogReq req)
{
- throw new ServiceException("导出功能暂未实现");
+ var logs = BuildAuditLogQuery(req).ToList();
+ var columns = new List
+ {
+ new ExcelHelper.DataColumnModel("日志ID", "Id", typeof(string),
+ (value, _) => FormatAuditId(value)),
+ new ExcelHelper.DataColumnModel("操作类型", "OperationType", typeof(string),
+ (value, _) => GetAuditOperationTypeName(value?.ToString())),
+ new ExcelHelper.DataColumnModel("目标类型", "TargetType", typeof(string),
+ (value, _) => GetAuditTargetTypeName(value?.ToString())),
+ new ExcelHelper.DataColumnModel("目标ID", "TargetId", typeof(string),
+ (value, _) => FormatAuditId(value)),
+ new ExcelHelper.DataColumnModel("目标名称", "TargetName", typeof(string)),
+ new ExcelHelper.DataColumnModel("操作详情", "OperationDetail", typeof(string)),
+ new ExcelHelper.DataColumnModel("操作结果", "Result", typeof(string)),
+ new ExcelHelper.DataColumnModel("操作人", "OptName", typeof(string)),
+ new ExcelHelper.DataColumnModel("操作时间", "OptDate", (value, _) =>
+ value == null ? string.Empty : ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss"))
+ };
+
+ new ExcelHelper().ListToExcel(
+ columns.ToArray(), logs, "风控操作日志", true, out var buffer);
+ return buffer;
}
///
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index f27ec368..75f98fbd 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -267,9 +267,7 @@ namespace YLErp.Modules.SwapModule
// 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。
// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。
// 与互换/提前终止 InitIncome(L447) 保持一致。
- unwindData.ClosePercent = unwindData.NotionalValue > 0
- ? unwindData.PosiNotionalValue / unwindData.NotionalValue
- : 1;
+ unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue);
unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
unwindData.CloseQty = unwindData.PositionQty;
if (position != null)
@@ -755,6 +753,18 @@ namespace YLErp.Modules.SwapModule
return remainingClosePercent * posiNotionalValue / notionalValue;
}
+ ///
+ /// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。
+ /// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。
+ /// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);
+ /// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。
+ /// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。
+ ///
+ public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue)
+ {
+ return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
+ }
+
///
/// 获取固定利率
///
diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
index b1d5316f..6865b195 100644
--- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs
@@ -2069,11 +2069,12 @@ namespace YLErp.Modules.SwapModule
/// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息
/// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为
/// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。
+ /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。
///
- private decimal CalculateSwapRealizedPnl(eod_swap_position position)
+ public static decimal CalculateSwapRealizedPnl(eod_swap_position position)
{
var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;
- if (marginTypes.Contains(position.InterestMode))
+ if (ConsTrade.InterestMarginModels.Contains(position.InterestMode))
{
interestRatio = -interestRatio;
}
diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs
index e78e82ff..7d45a901 100644
--- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs
+++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs
@@ -1,4 +1,4 @@
-using BaseOUDAL;
+using BaseOUDAL;
using YLErp.BLL;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
@@ -210,18 +210,7 @@ namespace YLErp.Modules.TradeModule
&& (x.EventType == (int)SwapEventTypeEnum.平仓 || x.EventType == (int)SwapEventTypeEnum.互换))
.OrderByDescending(x => x.id)
.FirstOrDefault();
- if (swapEvent != null && !string.IsNullOrEmpty(swapEvent.EventData))
- {
- try
- {
- var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(swapEvent.EventData);
- if (unwindData != null)
- {
- ctx.CurrentNotional = Math.Abs((double)unwindData.CloseNotionalValue);
- }
- }
- catch { }
- }
+ ctx.CurrentNotional = CalcSwapCloseNotionalFromEventData(swapEvent?.EventData);
}
else
{
@@ -230,15 +219,41 @@ namespace YLErp.Modules.TradeModule
.Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted)
.OrderByDescending(t => t.id)
.FirstOrDefault();
- if (tc != null && tc.UnwindPercentRate.HasValue && td.OriginalStockEqvNotional.HasValue)
- {
- ctx.CurrentNotional = Math.Abs(td.OriginalStockEqvNotional.Value * tc.UnwindPercentRate.Value);
- }
+ ctx.CurrentNotional = CalcOptionCloseNotional(td.OriginalStockEqvNotional, tc?.UnwindPercentRate);
}
}
return ctx;
}
+ ///
+ /// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue 绝对值。
+ /// 容错:EventData 为 null/空/非法 JSON / unwindData=null 时返回 0(不影响审批阈值判断)。
+ /// 抽为静态纯函数以支持无库单测。
+ ///
+ public static double CalcSwapCloseNotionalFromEventData(string eventData)
+ {
+ if (string.IsNullOrEmpty(eventData)) return 0;
+ try
+ {
+ var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(eventData);
+ return unwindData != null ? Math.Abs((double)unwindData.CloseNotionalValue) : 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ ///
+ /// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值。
+ /// 容错:任一参数为 null 时返回 0。抽为静态纯函数以支持无库单测。
+ ///
+ public static double CalcOptionCloseNotional(double? originalStockEqvNotional, double? unwindPercentRate)
+ {
+ if (!originalStockEqvNotional.HasValue || !unwindPercentRate.HasValue) return 0;
+ return Math.Abs(originalStockEqvNotional.Value * unwindPercentRate.Value);
+ }
+
///
/// 需求①:判断按触发条件是否需要审批。
/// 取该交易类别的审批流程,若所有节点配置的触发条件都不满足当前业务,则无需审批(返回 false)。
diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs
index b30a905d..028c0fba 100644
--- a/YLErpWeb/Controllers/RiskRuleController.cs
+++ b/YLErpWeb/Controllers/RiskRuleController.cs
@@ -151,12 +151,14 @@ namespace YLErp.Web.Controllers
[HttpDelete("risk-rules/batch")]
[MyAuthorize("风险控制-风控规则删除")]
/// 批量删除风控规则
- public async Task BatchDeleteRiskRules([FromBody] List ids)
+ public async Task BatchDeleteRiskRules([FromBody] BatchIdsReq req)
{
try
{
var service = GetRiskRuleService();
- var result = await Task.Run(() => service.BatchDeleteRules(ids));
+ var result = await Task.Run(() => service.BatchDeleteRules(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
return Json(new { success = true, data = result });
}
catch (ServiceException ex)
@@ -200,8 +202,13 @@ namespace YLErp.Web.Controllers
try
{
var service = GetRiskRuleService();
- await Task.Run(() => service.DisableRule(id));
- return Json(new { success = true });
+ var result = await Task.Run(() => service.DisableRule(id));
+ return Json(new
+ {
+ success = true,
+ cascadedAppIds = result.CascadedApplicationIds,
+ cascadedAppCount = result.CascadedApplicationIds.Count
+ });
}
catch (ServiceException ex)
{
@@ -214,6 +221,60 @@ namespace YLErp.Web.Controllers
}
}
+ [HttpPost("risk-rules/batch-enable")]
+ [MyAuthorize("风险控制-风控规则启停")]
+ /// 批量启用风控规则
+ public async Task BatchEnableRiskRules([FromBody] BatchIdsReq req)
+ {
+ try
+ {
+ var service = GetRiskRuleService();
+ var result = await Task.Run(() => service.BatchEnableRules(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
+ return Json(new { success = true, data = result });
+ }
+ catch (ServiceException ex)
+ {
+ return Json(new { success = false, message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "批量启用风控规则");
+ return Json(new { success = false, message = "系统异常,请联系管理员" });
+ }
+ }
+
+ [HttpPost("risk-rules/batch-disable")]
+ [MyAuthorize("风险控制-风控规则启停")]
+ /// 批量停用风控规则
+ public async Task BatchDisableRiskRules([FromBody] BatchIdsReq req)
+ {
+ try
+ {
+ var service = GetRiskRuleService();
+ var result = await Task.Run(() => service.BatchDisableRules(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
+ return Json(new
+ {
+ success = true,
+ data = result,
+ cascadedAppIds = result.CascadedApplicationIds,
+ cascadedAppCount = result.CascadedApplicationIds.Count
+ });
+ }
+ catch (ServiceException ex)
+ {
+ return Json(new { success = false, message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "批量停用风控规则");
+ return Json(new { success = false, message = "系统异常,请联系管理员" });
+ }
+ }
+
[HttpGet("risk-rules/{id}/applications")]
[MyAuthorize("风险控制-风控规则查看")]
/// 获取规则关联的应用配置列表
@@ -238,7 +299,7 @@ namespace YLErp.Web.Controllers
[HttpGet("risk-rules/list")]
[MyAuthorize("风险控制-风控规则查看")]
- /// 获取所有启用规则列表(供下拉选择)
+ /// 获取所有未删除规则列表(供下拉选择)
public async Task GetAllRiskRules()
{
try
@@ -419,12 +480,14 @@ namespace YLErp.Web.Controllers
[HttpPost("risk-applications/batch-enable")]
[MyAuthorize("风险控制-风控应用启停")]
/// 批量启用应用配置
- public async Task BatchEnableRiskApplications([FromBody] List ids)
+ public async Task BatchEnableRiskApplications([FromBody] BatchIdsReq req)
{
try
{
var service = GetRiskRuleService();
- var result = await Task.Run(() => service.BatchEnableApplications(ids));
+ var result = await Task.Run(() => service.BatchEnableApplications(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
return Json(new { success = true, data = result });
}
catch (ServiceException ex)
@@ -441,12 +504,14 @@ namespace YLErp.Web.Controllers
[HttpPost("risk-applications/batch-disable")]
[MyAuthorize("风险控制-风控应用启停")]
/// 批量停用应用配置
- public async Task BatchDisableRiskApplications([FromBody] List ids)
+ public async Task BatchDisableRiskApplications([FromBody] BatchIdsReq req)
{
try
{
var service = GetRiskRuleService();
- var result = await Task.Run(() => service.BatchDisableApplications(ids));
+ var result = await Task.Run(() => service.BatchDisableApplications(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
return Json(new { success = true, data = result });
}
catch (ServiceException ex)
@@ -460,6 +525,30 @@ namespace YLErp.Web.Controllers
}
}
+ [HttpDelete("risk-applications/batch")]
+ [MyAuthorize("风险控制-风控应用删除")]
+ /// 批量删除应用配置
+ public async Task BatchDeleteRiskApplications([FromBody] BatchIdsReq req)
+ {
+ try
+ {
+ var service = GetRiskRuleService();
+ var result = await Task.Run(() => service.BatchDeleteApplications(req?.Ids));
+ if (!result.Success)
+ return Json(new { success = false, message = result.ErrorMessage });
+ return Json(new { success = true, data = result });
+ }
+ catch (ServiceException ex)
+ {
+ return Json(new { success = false, message = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "批量删除应用配置");
+ return Json(new { success = false, message = "系统异常,请联系管理员" });
+ }
+ }
+
#endregion
#region Variable Management
@@ -683,7 +772,8 @@ namespace YLErp.Web.Controllers
{
var service = GetRiskRuleService();
var bytes = await Task.Run(() => service.ExportAuditLogs(req));
- return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "风控操作日志.xlsx");
+ var fileName = service.BuildAuditLogExportFileName(req);
+ return File(bytes, xlsxMimeType, fileName);
}
catch (ServiceException ex)
{