From 1bf517188cfa14f11cb51d2a681f7406c50ce4a0 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 7 Aug 2026 09:28:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20=E5=BE=85=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=88=86=E7=BA=A2=E6=94=B6=E7=9B=8A(DividendPending)=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20EOD=20=E5=85=A8=E9=87=8F=E5=8F=A3=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20e3c473ba=20=E7=9A=84=E7=A1=AC0=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e3c473ba 把前端 DividendPending 硬编码 0,对 GLMS-20260105-0004(PosiDividendSum 恰好=0) 测试通过、掩盖问题。但对 PosiDividendSum≠0 的部分平仓交易,硬0 会落库 (SwapFlowEventService:588 冲账取负写入 swap_flow_event.DividendPending)并在事件列表 "待结算分红收益"列显示错误的 0——是确定的回归。 口径论证(代码证据,非拍板):DividendPending(待结算存量) ↔ EOD PosiDividendSum(未实现), 字段名、递推公式(SwapEodPositionService:1825 已扣当日实现)、列标签(SwapflowList:561)、 旧前端意图(posiQty×totalInterest 想表达"平仓后剩余")四处同指全量口径。是存量非流量, 不按本次平仓比例分摊。 改动: - SwapDealService.InitUnwind:GetPreEodDividendSum 返回值取一次,同时喂 DividendIn 与 DividendPending(避免重复查库);GetPreEodDividendSum 注释补口径论证+历史教训(防后人改回硬0/分摊) - 前端 unwindSwapTrade.js:删除 DividendPending=0 硬写,保持后端值不动 - 前端 incomeSwapTrade.js:DividendPending 保持 0(互换=全量结清,结清后待结算归0), 注释说明与平仓页口径差异 - 测试补非0场景:GLMS-20260706-0004(PosiDividendSum=-150,000)验证返回非0真实值, 直接针对"硬0被0掩盖"盲区,防止该回归复现 全量构建 0 错误,4 个分红测试全绿(含 0 场景、互换页、无EOD防御、非0场景)。 --- ...GLMS20260105PartialCloseDividendBugTest.cs | 61 +++++++++++++++++++ .../Modules/SwapModule/SwapDealService.cs | 28 +++++++-- .../Scripts/app/swaptrade/incomeSwapTrade.js | 4 +- .../Scripts/app/swaptrade/unwindSwapTrade.js | 12 ++-- 4 files changed, 94 insertions(+), 11 deletions(-) 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(); },