diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs
new file mode 100644
index 00000000..77fb0841
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs
@@ -0,0 +1,152 @@
+using YLErp.DBModels;
+using YLErp.DBModels.Enums;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// 多次部分平仓"返回预付金"默认显示仍是初始值 bug 的回归测试(根因修复后应为全绿)。
+ /// ---------------------------------------------------------------
+ /// 生产铁证 GLMS-20260701-0008(SwapTradeId=1993,dev DB 192.168.2.96 / glms_yltrs_ylcms):
+ /// 预付金腿(InterestMode=5) 双轨记录——
+ /// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值
+ /// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=73,260) ← 实时腿,已扣减 2 次平仓(9,900+15,840)
+ ///
+ /// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿
+ /// InterestPrincipalFix 恒=99,000;而"当前剩余本金"73,260 存在 real 腿。GetInterests 算
+ /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix,
+ /// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。
+ /// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。
+ ///
+ /// 修复:SwapDealService.ResolveInterestLegPositions —— 迭代源仍用 origPositions(保留
+ /// orig.id → eod_swap_position.PositionId 的日终匹配,全库 25,441 行 eod 均按 orig.id 归档,
+ /// 换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿(初始5/追加6) Clone 覆盖
+ /// InterestPrincipalFix 为实时腿剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
+ ///
+ /// 覆盖盲区说明:既有 SwapUnwindPrepayPrincipalBugTdd 的 19 个用例全部直接调 GetInterests
+ /// 并只喂一条 IsInitial=true 的持仓,完全绕过 GetUnwindInterests 的 orig-vs-real 选择逻辑,
+ /// 测不到本次 bug。本类直接单测抽出的纯函数 ResolveInterestLegPositions 以锁定该契约。
+ ///
+ [TestClass]
+ public class SwapUnwindPrepayOrigVsRealBugTdd
+ {
+ // 生产 GLMS-20260701-0008 精确值
+ private const long OrigId = 35798;
+ private const long RealId = 35871;
+ private const decimal InitialFix = 99_000m; // orig 腿初始本金
+ private const decimal RemainingFix = 73_260m; // real 腿剩余本金(已扣减 2 次平仓)
+
+ private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.初始预付金)
+ => new swap_position
+ {
+ id = OrigId,
+ SwapTradeId = 1993,
+ PosiDirection = 0, // 利息端(收/支)
+ InterestMode = mode,
+ InterestPrincipalFix = fix,
+ IsInitial = true,
+ Invalid = false
+ };
+
+ private static swap_position RealPrepay(long positionId = OrigId, decimal fix = RemainingFix, int mode = (int)InterestModeEnum.初始预付金)
+ => new swap_position
+ {
+ id = RealId,
+ SwapTradeId = 1993,
+ PositionId = positionId, // 指向对应 orig 的 id
+ PosiDirection = 0,
+ InterestMode = mode,
+ InterestPrincipalFix = fix,
+ IsInitial = false,
+ Invalid = false
+ };
+
+ [TestMethod]
+ public void 多次部分平仓后_预付金腿本金应取实时腿剩余本金_而非原始腿初始值()
+ {
+ var origs = new List { OrigPrepay() };
+ var reals = new List { RealPrepay() };
+
+ var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
+
+ Assert.AreEqual(1, result.Count, "应保留 1 条利息腿");
+ Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
+ "多次部分平仓后:预付金腿本金应=实时腿剩余本金 73,260,而非原始腿初始值 99,000(bug 症状)");
+ // 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计)
+ Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix,
+ "修复必须走 Clone,绝不能就地改写 origPositions 的初始本金");
+ }
+
+ [TestMethod]
+ public void 首次平仓_实时腿等于原始腿_返回原始腿本身_零改动()
+ {
+ var origs = new List { OrigPrepay(InitialFix) };
+ var reals = new List { RealPrepay(fix: InitialFix) }; // 尚未平仓,real==orig
+
+ var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
+
+ Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "首次平仓 orig==real,本金保持初始值");
+ Assert.AreSame(origs[0], result[0], "orig==real 时不应克隆,直接返回原始腿本身(行为与修复前一致)");
+ }
+
+ [TestMethod]
+ public void 追加预付金腿_同样取实时腿剩余本金()
+ {
+ var origs = new List { OrigPrepay(InitialFix, (int)InterestModeEnum.追加预付金) };
+ var reals = new List { RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.追加预付金) };
+
+ var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
+
+ Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix,
+ "追加预付金(mode=6)与初始预付金(mode=5)同源修复,同样取实时腿剩余本金");
+ }
+
+ [TestMethod]
+ public void 非预付金腿_不受影响_始终保持原始腿本金()
+ {
+ // 标的期初全价(=9)等非预付金腿:即便 real 腿本金不同也不应被覆盖(其本金语义不同,不走此纠正)
+ var orig = OrigPrepay(InitialFix, (int)InterestModeEnum.标的期初全价);
+ var real = RealPrepay(fix: RemainingFix, mode: (int)InterestModeEnum.标的期初全价);
+ var result = SwapDealService.ResolveInterestLegPositions(
+ new List { orig }, new List { real });
+
+ Assert.AreEqual(InitialFix, result[0].InterestPrincipalFix, "非预付金腿本金不被实时腿覆盖");
+ Assert.AreSame(orig, result[0], "非预付金腿应原样返回,不克隆");
+ }
+
+ [TestMethod]
+ public void 无匹配实时腿_返回原始腿()
+ {
+ // real 腿 PositionId 指向别的 orig(或根本没有实时腿)→ 找不到匹配,保持原始腿
+ var origs = new List { OrigPrepay() };
+ var mismatched = new List { RealPrepay(positionId: 99999) };
+
+ var r1 = SwapDealService.ResolveInterestLegPositions(origs, mismatched);
+ Assert.AreEqual(InitialFix, r1[0].InterestPrincipalFix, "无匹配实时腿:保持原始腿初始本金");
+
+ var r2 = SwapDealService.ResolveInterestLegPositions(origs, new List());
+ Assert.AreEqual(InitialFix, r2[0].InterestPrincipalFix, "实时腿为空:保持原始腿初始本金");
+
+ var r3 = SwapDealService.ResolveInterestLegPositions(origs, null);
+ Assert.AreEqual(InitialFix, r3[0].InterestPrincipalFix, "实时腿为 null:应容错并保持原始腿初始本金");
+ }
+
+ [TestMethod]
+ public void 只保留利息腿_过滤掉标的腿()
+ {
+ // PosiDirection>0 的标的腿不属于利息端,应被过滤(与原实现 Where(PosiDirection==0) 一致)
+ var underlyingLeg = new swap_position
+ {
+ id = 40000, SwapTradeId = 1993, PosiDirection = 1,
+ InterestMode = (int)InterestModeEnum.标的期初全价, IsInitial = true, Invalid = false
+ };
+ var origs = new List { OrigPrepay(), underlyingLeg };
+ var reals = new List { RealPrepay() };
+
+ var result = SwapDealService.ResolveInterestLegPositions(origs, reals);
+
+ Assert.AreEqual(1, result.Count, "只应保留利息腿(PosiDirection==0),标的腿被过滤");
+ Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿");
+ Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金");
+ }
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index f338a2a4..26285156 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -500,7 +500,10 @@ namespace YLErp.Modules.SwapModule
var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList();
var origPositions = allpositions.Where(x => x.IsInitial).ToList();
var realPostitions = allpositions.Where(x => !x.IsInitial).ToList();
- var positions = origPositions.Where(x => x.PosiDirection == 0).ToList();
+ // 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。
+ // 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配),
+ // 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。
+ var positions = ResolveInterestLegPositions(origPositions, realPostitions);
var fpositions = origPositions.Where(x => x.PosiDirection > 0).ToList();
var longPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).ToList();
var shortPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).ToList();
@@ -521,6 +524,40 @@ namespace YLErp.Modules.SwapModule
return interests;
}
+ ///
+ /// 解析利息腿(PosiDirection==0)持仓,供 GetUnwindInterests 使用。抽为纯函数以便无库单测。
+ /// 根因(多次部分平仓预付金返还错误):预付金腿(初始/追加)的"当前剩余本金"存于实时持仓
+ /// realPositions.InterestPrincipalFix,每次平仓由 UpdateInitalPosition 递减;而原始腿
+ /// origPositions(IsInitial=1)的 InterestPrincipalFix 恒为初始值。GetInterests 算
+ /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv(InitSwapDealInterest) 时都读
+ /// position.InterestPrincipalFix,若沿用原始腿,会在多次部分平仓后仍返还/计算初始本金(如始终 99000)。
+ /// 修复:迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配,
+ /// 全库实测 eod 均按 orig.id 归档;若换 realPositions 会破坏 preEod 匹配导致利息重算错误),仅对预付金腿
+ /// Clone 覆盖其本金值为实时腿的剩余本金。real 与 orig 通过 real.PositionId == orig.id 精确 1:1 关联。
+ /// 首次平仓时 orig==real 行为不变;仅在发生过部分平仓后 real≠orig 时用实时腿本金纠正。
+ ///
+ /// 原始腿(IsInitial=1)全集
+ /// 实时腿(IsInitial=0)全集,其 PositionId 指向对应 orig 的 id
+ /// 利息腿(PosiDirection==0)列表:预付金腿本金已对齐实时剩余本金,其余保持原始腿
+ public static List ResolveInterestLegPositions(List origPositions, List realPositions)
+ {
+ realPositions ??= new List();
+ return origPositions.Where(x => x.PosiDirection == 0).Select(p =>
+ {
+ if (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)
+ {
+ var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id);
+ if (realLeg != null && realLeg.InterestPrincipalFix != p.InterestPrincipalFix)
+ {
+ var clone = p.Clone();
+ clone.InterestPrincipalFix = realLeg.InterestPrincipalFix;
+ return clone;
+ }
+ }
+ return p;
+ }).ToList();
+ }
+
///
/// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。
/// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。