diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs
new file mode 100644
index 00000000..774059a2
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json;
+using YLErp;
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+using YLErp.Modules.SwapModule;
+
+namespace UnitTestProject.Modules.SwapModule
+{
+ ///
+ /// 【缺陷证明测试】界面「全部平仓 → 部分平仓」,改变平仓比例,下方利息腿数据不变。
+ /// ------------------------------------------------------------------------------
+ /// 现象(前端):SwapUnwind.cshtml 的 ClosePercent 输入框 → changeClosePercent →
+ /// getInterestList → POST /swaptrade2/GetUnwindInterestList → 后端返回的利息腿列表数值不随比例变化。
+ ///
+ /// 根因(后端,本测试要钉死的):SwapDealService.CalcNotionalByMode
+ /// case InterestModeEnum.固定值:
+ /// closePrincipal = posiPrincipal = position.InterestPrincipalFix;
+ /// newClosePercent = 1m; // ← 这一行把平仓比例直接抹成 1
+ /// 固定值利息腿的计息本金恒等于 InterestPrincipalFix(与比例无关),
+ /// 同时 newClosePercent 被强制置 1,后续 CalcDailySimpleInterest / CalcDailyCompoundInterest
+ /// 拿到的 closePercent 也是 1 → 无论前端传 30% / 50% / 70% / 100%,返回利息完全相同。
+ ///
+ /// 对照组:InterestModeEnum.标的期初全价(9) 走 closePrincipal = posiNotional * closePercent,
+ /// 线性缩放,作为「正确行为」的基准。若对照组也不变,说明问题不在本函数(排除法)。
+ ///
+ /// 调用方式:完全复刻生产 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造——
+ /// posiNotionalValue = stockEqvNotional(剩余名义本金,不乘比例)
+ /// posiLongNotionalValue = 多头剩余
+ /// closePosiNotionalValue = stockEqvNotional * closePercent
+ /// closePrecent = closePercent(控制器已做 A→B 口径转换)
+ /// add=true, settment=false(盘中预览,不落库)
+ /// 因此这是**真实生产函数**的行为,不是夹具自造逻辑。
+ ///
+ /// 期望(修复前 红 / 修复后 绿):固定值腿利息应与 closePercent 成正比。
+ ///
+ [TestClass]
+ public class SwapFixedInterestLegClosePercentBugTest
+ {
+ private const decimal Notional = 100_000_000m; // 期初=剩余名义本金 1 亿
+ private const decimal FixedRate = 0.03m; // 固定年利率 3%
+ private const int AnnualDays = 365;
+
+ private static readonly DateTime StartDate = new DateTime(2026, 4, 21);
+ private static readonly DateTime Maturity = new DateTime(2026, 6, 30);
+ private static readonly DateTime CloseDate = new DateTime(2026, 5, 11);
+
+ #region Stub(无库)
+
+ private sealed class StubSvc : SwapDealService
+ {
+ public StubSvc() : base(new OptUserInfo(0, nameof(SwapFixedInterestLegClosePercentBugTest), OptUserFrom.UnitTest)) { }
+ // 无库环境:已消耗利息视为 0
+ public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
+ }
+
+ #endregion
+
+ #region 构造器
+
+ private static trade CreateTrade(string interestCalcMode)
+ {
+ var extend = new trade_extend
+ {
+ TradeId = 1,
+ ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
+ {
+ AnnualDays = AnnualDays,
+ InterestCalcMode = interestCalcMode,
+ SettlementRules = 0
+ })
+ };
+ return new trade
+ {
+ id = 1,
+ TradeNumber = "UT-CLOSEPCT-BUG",
+ ClientId = 999998,
+ TradeType = "收益互换",
+ TradeDate = StartDate,
+ StartDate = StartDate,
+ ExerciseDate = Maturity,
+ TradeStatus = "确认成交",
+ ValidState = "Valid",
+ trade_extend = extend
+ };
+ }
+
+ /// 固定利率利息腿(不走浮动曲线),仅 InterestMode 不同
+ private static swap_position CreateLeg(int interestMode, InterestTypeEnum interestType)
+ {
+ var intervalModels = new List
+ {
+ new IntervalModel { Date = Maturity, Rate = FixedRate, Settlement = 0 }
+ };
+ return new swap_position
+ {
+ id = 1001,
+ SwapTradeId = 1,
+ PositionType = (int)PositionTypeFlag.Unknown,
+ PosiDirection = 0, // 利息腿
+ InterestDirection = (int)SwapDirectionEnum.收取,
+ InterestMode = interestMode,
+ InterestRateDefault = FixedRate,
+ InterestPrincipalFix = Notional, // 固定值腿的计息本金
+ PosiStartDate = StartDate,
+ PosiMatuirityDate = Maturity,
+ IsInitial = true,
+ Invalid = false,
+ InterestType = (int)interestType,
+ IsAnnualized = true,
+ interest_rest_days = 1,
+ interest_rule = 0,
+ FloatRateUnderlyingCode = null, // 固定利率
+ InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
+ };
+ }
+
+ ///
+ /// 复刻 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造,
+ /// 唯一变量是界面输入的 closePercent。
+ ///
+ private static swap_flow_event CallProductionPath(trade td, swap_position leg, decimal closePercent)
+ {
+ var svc = new StubSvc();
+ var stockEqvNotional = Notional; // 剩余名义本金
+ var posiLongNotionalValue = Notional;
+ var posiShortNotionalValue = 0m;
+ var closePosiNotionalValue = stockEqvNotional * closePercent; // 生产:posiNotionalValue = stockEqvNotional * closePercent
+ var orginPv = stockEqvNotional;
+
+ var interests = svc.GetInterests(
+ td, td.trade_extend, CloseDate, CloseDate,
+ new List(), // 无上一日 EOD
+ new List { leg },
+ stockEqvNotional,
+ posiLongNotionalValue,
+ posiShortNotionalValue,
+ closePosiNotionalValue,
+ closePercent,
+ (int)SwapEventTypeEnum.平仓,
+ tdClose: false,
+ needPrice: false,
+ grossPrice: 0m,
+ orginPv: orginPv,
+ add: true,
+ settment: false);
+
+ Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿");
+ return interests[0];
+ }
+
+ #endregion
+
+ #region 对照组:标的期初全价(9) —— 正确行为,比例线性缩放
+
+ [DataTestMethod]
+ [DataRow("11")] // 算头算尾
+ [DataRow("10")] // 算头不算尾
+ public void 对照组_标的期初全价腿_利息应随平仓比例线性变化(string calcMode)
+ {
+ var td = CreateTrade(calcMode);
+ var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), 1m);
+
+ Console.WriteLine($"[对照组 mode=9 calcMode={calcMode}] 100% 利息 = {full.InterestAmount}");
+ Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度");
+
+ foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
+ {
+ var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), pct);
+ var expect = full.InterestAmount * pct;
+ Console.WriteLine($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 差={part.InterestAmount - expect}");
+ Assert.AreEqual((double)expect, (double)part.InterestAmount, 0.01,
+ $"mode=9 应线性缩放:{pct:P0}");
+ }
+ }
+
+ #endregion
+
+ #region 缺陷组:固定值(1) —— 当前不随比例变化(修复前红)
+
+ [DataTestMethod]
+ [DataRow("11", 0)] // 算头算尾 + 单利
+ [DataRow("10", 0)] // 算头不算尾 + 单利
+ [DataRow("11", 1)] // 算头算尾 + 复利
+ [DataRow("10", 1)] // 算头不算尾 + 复利
+ public void 缺陷_固定值利息腿_利息必须随平仓比例线性变化(string calcMode, int typeFlag)
+ {
+ var type = typeFlag == 1 ? InterestTypeEnum.复利 : InterestTypeEnum.单利;
+ var td = CreateTrade(calcMode);
+ var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), 1m);
+
+ Console.WriteLine($"[缺陷组 mode=1(固定值) calcMode={calcMode} {type}] 100% 利息 = {full.InterestAmount}");
+ Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度");
+
+ var diffs = new List();
+ var unchanged = 0;
+ foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
+ {
+ var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct);
+ var expect = full.InterestAmount * pct;
+ var delta = part.InterestAmount - expect;
+ if (part.InterestAmount == full.InterestAmount) unchanged++;
+ diffs.Add($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 偏差={delta}");
+ }
+ diffs.ForEach(Console.WriteLine);
+ if (unchanged == 3)
+ {
+ Console.WriteLine(" ▲ 三个比例返回值与 100% 完全一致 → 复现「界面改比例利息腿数据不变」");
+ Console.WriteLine(" ▲ 根因:CalcNotionalByMode case 固定值 → newClosePercent = 1m");
+ }
+
+ foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
+ {
+ var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct);
+ Assert.AreEqual((double)(full.InterestAmount * pct), (double)part.InterestAmount, 0.01,
+ $"固定值腿应随比例缩放,但 {pct:P0} 与全平返回相同值(Bug)");
+ }
+ }
+
+ #endregion
+
+ #region 单元级:直接断言 CalcNotionalByMode 的比例语义
+
+ ///
+ /// 反射直击私有方法 CalcNotionalByMode,剥离所有计息细节,
+ /// 只看「(closePrincipal, newClosePercent) 是否体现了平仓比例」。
+ /// 注意:closePrincipal 与 newClosePercent 作用在**不同项**上——
+ /// closePrincipal 决定"本期新增利息"的基数,newClosePercent 决定"历史累计/已消耗利息"的缩放,
+ /// 二者不相乘(相乘会得到双重缩放的错误度量)。因此这里分别断言:
+ /// 1) closePrincipal 应 = Fix × pct
+ /// 2) newClosePercent 应 = pct(而非被强制置 1)
+ ///
+ [DataTestMethod]
+ [DataRow((int)InterestModeEnum.固定值, "0.3")]
+ [DataRow((int)InterestModeEnum.固定值, "0.5")]
+ [DataRow((int)InterestModeEnum.固定值, "0.7")]
+ public void CalcNotionalByMode_固定值腿_有效缩放系数必须等于平仓比例(int mode, string pctStr)
+ {
+ var pct = decimal.Parse(pctStr, System.Globalization.CultureInfo.InvariantCulture);
+ var leg = CreateLeg(mode, InterestTypeEnum.单利);
+ var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ Assert.IsNotNull(m, "未找到 CalcNotionalByMode,签名可能已变更");
+
+ var full = m.Invoke(new StubSvc(), new object[] { leg, 1m, Notional, Notional, 0m });
+ var part = m.Invoke(new StubSvc(), new object[] { leg, pct, Notional, Notional, 0m });
+
+ var fullClose = (decimal)full.GetType().GetField("Item1").GetValue(full);
+ var fullPct = (decimal)full.GetType().GetField("Item3").GetValue(full);
+ var partClose = (decimal)part.GetType().GetField("Item1").GetValue(part);
+ var partPct = (decimal)part.GetType().GetField("Item3").GetValue(part);
+
+ Console.WriteLine($"[CalcNotionalByMode mode={mode}] 100%: closePrincipal={fullClose} newClosePercent={fullPct}");
+ Console.WriteLine($"[CalcNotionalByMode mode={mode}] {pct:P0}: closePrincipal={partClose} newClosePercent={partPct}");
+
+ // 1) 计息本金必须按比例缩放(本期新增利息的基数)
+ Assert.AreEqual((double)(fullClose * pct), (double)partClose, 0.01,
+ $"固定值腿 closePrincipal 应为 Fix×{pct:P0},实际未缩放");
+ // 2) 传给 CalcDaily*Interest 的比例必须是真实平仓比例(历史累计/已消耗利息的缩放)
+ Assert.AreEqual((double)pct, (double)partPct, 1e-9,
+ $"固定值腿 newClosePercent 应为 {pct:P0},被强制置 1 会抹掉比例语义");
+ }
+
+ #endregion
+ }
+}
diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
index e1124ec7..557bdb25 100644
--- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
+++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs
@@ -535,13 +535,17 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5");
}
- // ---- 固定值(1):CalcNotionalByMode 强制 newClosePercent=1,对 closePercent 免疫(输入 0.5 也不缩放) ----
+ // ---- 固定值(1):修复后应与所有其他模式一致,按平仓比例线性缩放 ----
+ // 历史缺陷:CalcNotionalByMode 对固定值腿同时把 closePrincipal 钉成 Fix 全量、newClosePercent 强制置 1,
+ // 导致「全部平仓→部分平仓」改比例时利息腿数据完全不变(与用户预期及其他模式行为不符,GLMS 缺陷)。
+ // 修复见 SwapDealService.cs CalcNotionalByMode 固定值分支(引入提交 95686f4c,吴方海,EQD-5718,2026-05-08)。
+ // 旧断言「对平仓比例免疫、恒=Fix」本身就是在保卫该缺陷,此处纠正为正确的线性缩放。
[TestMethod]
- public void 固定值腿_盘中_部分平仓_对平仓比例免疫_返回Fix本金()
+ public void 固定值腿_盘中_部分平仓_应随平仓比例线性缩放()
{
const decimal baseP = 2_000_000m;
var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m);
- Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿 newClosePercent=1,InterestPrincipal 恒=Fix,不随平仓比例缩放");
+ Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "固定值腿修复后 50% 应=Fix×0.5(与其他模式一致,缺陷已修复)");
}
// ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ----
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index 94f6b0a1..d6e6ecfe 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -899,8 +899,18 @@ namespace YLErp.Modules.SwapModule
switch ((InterestModeEnum)position.InterestMode)
{
case InterestModeEnum.固定值:
- closePrincipal = posiPrincipal = position.InterestPrincipalFix;
- newClosePercent = 1m;
+ // 【缺陷修复】界面「全部平仓→部分平仓」改比例,下方利息腿数据完全不变。
+ // 原实现:closePrincipal = posiPrincipal = Fix; newClosePercent = 1m;
+ // 固定值腿的"计息本金"确实固定(Fix),但"本次平仓结算/返还多少利息"必须按平仓比例缩放。
+ // 旧代码把 closePrincipal 与 newClosePercent 两个缩放入口同时抹平,
+ // 导致 GetUnwindInterests 盘中预览无论传 30%/50%/70%/100%,返回利息完全相同。
+ // 修复口径:与 初始/追加预付金 腿(下方 case)以及 标的期初全价(mode 9) 完全一致——
+ // closePrincipal 承担"本期新增利息"的缩放,newClosePercent 承担"历史累计/已消耗利息"的缩放,
+ // 二者作用在不同项上,不会双重缩放(mode 9 已由回归测试证明线性)。
+ // 兼容性:EOD 结算路径 SwapEodPositionService:1350 传 closePrecent = 1 字面量,
+ // 此处 Fix × 1 = Fix,日终行为与修复前完全一致。
+ closePrincipal = position.InterestPrincipalFix * closePercent;
+ posiPrincipal = position.InterestPrincipalFix;
break;
case InterestModeEnum.多头存续名义本金:
closePrincipal = posiLong * closePercent;