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/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)。