diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
index a55c47b6..d3ee8e5f 100644
--- a/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
@@ -154,5 +154,66 @@ namespace YLErp.Modules.SwapModule
}
finally { db?.Dispose(); }
}
+
+ // ============================================================================
+ // 非0场景验收(关键防回归)
+ // ----------------------------------------------------------------------------
+ // GLMS-20260105-0004 的 PosiDividendSum 恰好=0(3/2 已全额互换),无法暴露
+ // "DividendPending 硬编码 0" 的回归。本测试用一笔 PosiDividendSum≠0 的活交易
+ // GLMS-20260706-0004(标的 230004.IB,持仓 1亿,待实现分红 -150,000,多日稳定)
+ // 验证:GetPreEodDividendSum 返回非0的 -150,000 → 修复后 DividendIn/DividendPending
+ // 均应为此值(而非历史硬0回归)。
+ // 若本测试因硬0回归而失败(实际=0),即说明有人把 DividendPending 改回了硬0,
+ // 或 GetPreEodDividendSum 传导链路被破坏。
+ // ============================================================================
+
+ ///
+ /// 非0场景:PosiDividendSum≠0 时,GetPreEodDividendSum 返回真实非0值,
+ /// 该值应同时成为 DividendIn 与 DividendPending(全量口径)。
+ /// 这是针对"硬0被0掩盖"盲区的核心防回归测试。
+ ///
+ [TestMethod]
+ [TestCategory("DBRecording")]
+ public void Green_NonZeroPosiDividendSum_Returns_RealValue_NotHardZero()
+ {
+ const string nonZeroTrade = "GLMS-20260706-0004"; // PosiDividendSum=-150,000,确认成交活交易
+ YLContext db;
+ try { db = DbContextFactory.GetYLDbContext(); }
+ catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
+
+ try
+ {
+ var td = db.trade.FirstOrDefault(t => t.TradeNumber == nonZeroTrade);
+ if (td == null) { Assert.Inconclusive($"测试库不存在交易 {nonZeroTrade}"); return; }
+
+ var floatLeg = db.swap_position
+ .Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
+ .FirstOrDefault();
+ Assert.IsNotNull(floatLeg, $"未找到 {nonZeroTrade} 的浮动腿持仓");
+
+ // 取该交易最近一次 EOD 的 PosiDividendSum 作为期望值(多日稳定 -150,000)
+ var lastEod = db.eod_swap_position
+ .Where(x => x.SwapTradeId == td.id && !x.Invalid && x.PositionId == floatLeg.PositionId && x.PosiQuantity > 0)
+ .OrderByDescending(x => x.ValueDate).FirstOrDefault();
+ Assert.IsNotNull(lastEod, $"未找到 {nonZeroTrade} 的有效 EOD");
+ Assert.AreNotEqual(0m, lastEod.PosiDividendSum, 0.01m,
+ $"前置:{nonZeroTrade} 的 PosiDividendSum 应≠0(本测试专为非0场景设计),实际={lastEod.PosiDividendSum}。" +
+ $"若该交易已互换/平仓致归0,请换另一笔 PosiDividendSum≠0 的活交易。");
+
+ // dealDate 取 lastEod 次日,确保 GetPreEodDividendSum 读到这笔非0 EOD
+ var dealDate = lastEod.ValueDate.AddDays(1);
+ var service = new RealDbSwapDealService();
+ decimal dividend = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, dealDate);
+
+ // ---- 核心:返回值=真实非0的 PosiDividendSum,不是硬0 ----
+ Assert.AreEqual(lastEod.PosiDividendSum, dividend, 0.01m,
+ $"[非0场景·方案C验收] {nonZeroTrade} GetPreEodDividendSum 应返回 EOD PosiDividendSum={lastEod.PosiDividendSum}," +
+ $"实际={dividend}。该值将同时成为 DividendPending(待结算分红收益,全量口径)。" +
+ $"若实际=0,说明 DividendPending 硬0回归未修复,或传导链路被破坏(参见 SwapDealService.GetPreEodDividendSum 注释的口径论证)。");
+
+ Console.WriteLine($"[非0场景验证通过] {nonZeroTrade}: PosiDividendSum={dividend}(非0)→ DividendIn/DividendPending 均为此值,非硬0。");
+ }
+ finally { db?.Dispose(); }
+ }
}
}
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index 3b44d623..2c0e6310 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -352,7 +352,13 @@ namespace YLErp.Modules.SwapModule
floatEvent.EventReason = "交易";
// 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
// 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
- floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
+ // 同一 EOD 值取一次喂两栏:
+ // DividendIn = "浮动端平仓盈亏·分红"(本次动作要落袋的,落库后被前端按需展示)
+ // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径,
+ // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归)
+ decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
+ floatEvent.DividendIn = preEodDividendSum;
+ floatEvent.DividendPending = preEodDividendSum;
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
@@ -962,7 +968,7 @@ namespace YLErp.Modules.SwapModule
///
/// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum),
- /// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)"。
+ /// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)" 与 "待结算分红收益(DividendPending)"。
/// 方案C:替代前端 totalInterest × 期初持仓 的重算——后者会把登记日前已平仓、
/// 不享有该笔分红的部分重复计入(GLMS-20260105-0004 误显 -36,160)。
/// EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"算出待实现分红,
@@ -973,10 +979,22 @@ namespace YLErp.Modules.SwapModule
/// 抽为 protected virtual:与 GetMaxIncomeValueDate 一致,便于测试替身覆写、
/// 也兼容无 EOD 的边界(返回 0,与历史 DividendIn=0 行为一致)。
///
- /// 互换交易ID
- /// 浮动腿持仓ID(swap_position.PositionId)
- /// 预览/平仓业务日期
/// 上一收盘日该浮动腿的待实现分红;无 EOD 记录返回 0
+ ///
+ /// 【口径论证·勿改】为什么 DividendPending 也用本方法的全量值(非分摊、非硬0):
+ /// 1. 字段语义直接对应:EOD PosiDividendSum 的 DisplayName="浮动端平仓盈亏·分红未实现"
+ /// (EodSwapPosition.cs:186),递推式 PosiDividendSum=前日+当日新计-当日实现
+ /// (SwapEodPositionService.cs:1825),即"扣过当日实现后、还挂在账上未来才结的存量"。
+ /// 前端列"待结算分红收益"(SwapflowList.js:561) 字面就是同一回事 → 直接取 PosiDividendSum。
+ /// 2. 是"存量"非"流量":DividendPending 描述的是"账上还欠多少"(与本次平仓比例无关的总额),
+ /// 而 DividendIn 才是"本次动作落袋多少"。两者口径本就不同,各自正确。若把 DividendPending 改成
+ /// 按本次平仓比例分摊,会把"存量"误当"流量",与列名"待结算"矛盾。
+ /// 3. 历史教训:方案C 初版曾把前端 DividendPending 硬编码 0(commit e3c473ba),因测试交易
+ /// PosiDividendSum 恰好=0(3/2 已全额互换)而测试通过、掩盖问题。但对 PosiDividendSum≠0 的部分
+ /// 平仓交易,硬0 会落库(SwapFlowEventService.cs:588 冲账取负写入 swap_flow_event.DividendPending)
+ /// 并在事件列表"待结算分红收益"列显示错误的 0 —— 这是确定的回归。故本方法返回值同时喂两栏,
+ /// 前端不得再覆盖。例外:互换页 DividendPending 保持 0(互换语义=全量结清,结清后待结算归0)。
+ ///
protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
{
var lastEod = DbContext.eod_swap
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index 264da52b..105f68c9 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
@@ -268,7 +268,9 @@ const vue = new Vue({
var thisObj = this;
// 方案C:分红改由后端 InitIncome 读 EOD PosiDividendSum 填入 floatPosition.DividendIn(单一可信源)。
// 前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓部分的重复计入。
- // floatPosition.DividendIn 保持后端返回值不动;互换为全量消费,DividendPending=0。
+ // floatPosition.DividendIn 保持后端返回值不动(=本次互换要落袋的全量待实现)。
+ // DividendPending(待结算)互换页保持 0:互换=全量结清,结清后账上无待结算。
+ // ↳ 与平仓页不同:平仓只拿走一部分,剩余持仓仍有待结算 → 平仓页 DividendPending=PosiDividendSum(后端值)。
thisObj.floatPosition.DividendPending = 0;
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
index 82db3722..1c038aef 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
@@ -387,11 +387,13 @@ const vue = new Vue({
},
getDivindIn() {
var thisObj = this;
- // 方案C:分红改由后端 InitUnwind 读 EOD PosiDividendSum 填入 floatPosition.DividendIn(单一可信源)。
- // 前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓部分的重复计入
- // (GLMS-20260105-0004 平仓前部分平仓40%后,再平仓时分红误显 -36,160,应为 0)。
- // floatPosition.DividendIn 保持后端返回值不动;DividendPending(纯展示列)置 0。
- thisObj.floatPosition.DividendPending = 0;
+ // 方案C:分红改由后端 InitUnwind 读 EOD PosiDividendSum 填入 floatPosition.DividendIn 与 DividendPending
+ // (单一可信源)。前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓
+ // 部分的重复计入(GLMS-20260105-0004 平仓前部分平仓40%后,再平仓时分红误显 -36,160,应为 0)。
+ // ⚠ floatPosition.DividendIn / DividendPending 均保持后端返回值不动,前端不得覆盖:
+ // - DividendIn(本次落袋)、DividendPending(待结算存量=PosiDividendSum 全量口径)。
+ // - 互换页 DividendPending 保持 0(互换=全量结清,结清后待结算归0),见 incomeSwapTrade.js。
+ // - 历史:曾硬编码 DividendPending=0,对 PosiDividendSum≠0 的部分平仓会落库错误的 0(回归)。
thisObj.calcFloatClosePnl();
thisObj.dataFormat();
},