diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
new file mode 100644
index 00000000..a55c47b6
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
@@ -0,0 +1,158 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using YLErp.DBModels;
+
+namespace YLErp.Modules.SwapModule
+{
+ ///
+ /// 【绿灯验收】国联民生-期间结算事件验证-分红前部分平仓过的交易,平仓与分红结束并收盘后,
+ /// 继续平仓 / 互换时"浮动端平仓盈亏·分红(DividendIn)"应为 0(修复方案C:后端读 EOD 单一可信源)。
+ /// ============================================================================
+ /// 参考交易:GLMS-20260105-0004(测试库 id=1921,标的 230004.IB)
+ /// 场景时间线(库内真实数据):
+ /// 2026-01-05 开仓,期初持仓 50,000,000(支付方向 Direction=2,多头 PositionType=1)
+ /// 2026-02-28 部分平仓 40%(20,000,000),剩余 30,000,000;该日早于 3/2 登记日,
+ /// 已平仓的 40% 不享有 3/2 付息 → 平仓事件 DividendIn=0(正确)
+ /// 2026-03-02 债券期间付息登记日;当日自动互换(EventType=4),按【剩余持仓 30,000,000】
+ /// 实现分红 DividendIn=-54,240(=30,000,000 × 0.001808);EOD PosiDividendSum=0(全实现)
+ /// 2026-03-03 收盘后对该交易继续平仓/互换
+ ///
+ /// 修复前(Bug):前端 getDivindIn 用期初全额持仓(50M)×totalInterest 算出 -90,400,
+ /// 扣除 consumedDividend(-54,240) 得 remainDividend=-36,160 展示 → 错误。
+ /// -36,160 恰=已平仓40%(20M)×单位付息(0.001808)×支付方向(-1),即把"登记日前已平仓、
+ /// 不享有该笔分红"的部分重复计入。
+ ///
+ /// 修复后(方案C):后端 InitUnwind/InitIncome 经 GetPreEodDividendSum 读上一收盘日
+ /// eod_swap_position.PosiDividendSum。3/2 互换后该值=0 → DividendIn=0(正确)。
+ /// 前端 getDivindIn 不再自算,直接用后端值。
+ /// ============================================================================
+ /// 本测试连真实测试库,调用真实 SwapDealService.GetPreEodDividendSum 验证修复后 DividendIn=0。
+ /// 连不上库时 Inconclusive 跳过(CI 无 DB 环境不挡)。
+ ///
+ [TestClass]
+ public class GLMS20260105PartialCloseDividendBugTest
+ {
+ private const string TradeNumber = "GLMS-20260105-0004";
+ // 3/3 继续平仓/互换的业务日期;上一收盘日为 3/2(EOD 已生成,PosiDividendSum=0)
+ private static readonly DateTime DealDate0303 = new(2026, 3, 3);
+
+ ///
+ /// 连真实库的 SwapDealService 子类,仅用于暴露 protected GetPreEodDividendSum 供单测调用。
+ /// 不 override 任何 seam → DbContext 走真实 YLContext(与 DbContextFactory.GetYLDbContext() 同库)。
+ ///
+ private sealed class RealDbSwapDealService : SwapDealService
+ {
+ public RealDbSwapDealService() : base(OptUserInfo.UnitTestUser) { }
+
+ public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
+ => GetPreEodDividendSum(tradeId, positionId, dealDate);
+ }
+
+ [TestMethod]
+ [TestCategory("DBRecording")]
+ public void Green_3_3_Unwind_DividendIn_FromEOD_Equals_Zero()
+ {
+ 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 == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
+
+ // 浮动腿持仓:非初始、UnderlyingCode 非空(当前剩余 30,000,000)
+ var positions = db.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid).ToList();
+ var floatLeg = positions.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial);
+ Assert.IsNotNull(floatLeg, "未找到浮动腿持仓(非初始、UnderlyingCode 非空)");
+
+ // ---- 调用真实 GetPreEodDividendSum(方案C 修复核心)----
+ var service = new RealDbSwapDealService();
+ decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
+
+ // ---- 绿灯断言:3/2 互换后 EOD PosiDividendSum=0 → DividendIn=0 ----
+ Assert.AreEqual(0m, dividendIn, 0.01m,
+ $"[绿灯·方案C验收] 3/3平仓/互换 DividendIn 期望=0(3/2互换后剩余持仓30M的分红已全部实现," +
+ $"EOD PosiDividendSum=0)。实际={dividendIn}。修复前该值为 -36,160(期初持仓×totalInterest 重算误计入已平仓40%)。");
+
+ // ---- 健全性:上一 EOD 确为 3/2,且 PosiDividendSum=0 ----
+ var lastEod = db.eod_swap_position
+ .Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId)
+ .OrderByDescending(x => x.ValueDate).FirstOrDefault();
+ Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录");
+ Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2");
+ Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m,
+ $"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)");
+ Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,000(2/28已平仓40%)");
+ }
+ finally { db?.Dispose(); }
+ }
+
+ ///
+ /// 互换页(InitIncome)与平仓页(InitUnwind)读同一 EOD:同 position、同 dealDate,
+ /// GetPreEodDividendSum 返回值必然一致。本测试明确覆盖"点击收益互换同理"路径
+ /// (用户反馈:平仓页 -36,160,点击收益互换同样错误)。
+ ///
+ [TestMethod]
+ [TestCategory("DBRecording")]
+ public void Green_3_3_Income_DividendIn_FromEOD_Equals_Zero()
+ {
+ 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 == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); 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, "未找到浮动腿持仓");
+
+ // InitIncome 与 InitUnwind 调用 GetPreEodDividendSum 的入参一致 → 结果一致
+ var service = new RealDbSwapDealService();
+ decimal incomeDividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
+
+ Assert.AreEqual(0m, incomeDividendIn, 0.01m,
+ $"[互换页·方案C验收] 3/3收益互换 DividendIn 期望=0,实际={incomeDividendIn}。" +
+ $"InitIncome 与 InitUnwind 共用 GetPreEodDividendSum,应返回同一 EOD 值。");
+ }
+ finally { db?.Dispose(); }
+ }
+
+ ///
+ /// 防御性:dealDate 早于任何 EOD(如交易首日尚未收盘)时,GetPreEodDividendSum 应返回 0,
+ /// 不抛异常。覆盖"无 EOD 记录"边界——fallback 到 dealDate.AddDays(-1) 后查无数据 → 0。
+ ///
+ [TestMethod]
+ [TestCategory("DBRecording")]
+ public void Green_NoEodBeforeDealDate_Returns_Zero_WithoutThrowing()
+ {
+ 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 == TradeNumber);
+ if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); 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, "未找到浮动腿持仓");
+
+ var service = new RealDbSwapDealService();
+ // dealDate = 交易开始日前一天,确保无任何 EOD 满足 ValueDate < dealDate
+ var preStartDate = td.StartDate.Value.AddDays(-1);
+ decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, preStartDate);
+
+ Assert.AreEqual(0m, dividendIn, 0.01m,
+ $"[防御] dealDate({preStartDate:yyyy-MM-dd})早于所有 EOD 时,GetPreEodDividendSum 应返回 0,实际={dividendIn}。" +
+ $"与历史 InitUnwind/InitIncome 中 DividendIn=0 行为一致,不抛异常。");
+ }
+ finally { db?.Dispose(); }
+ }
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
index cb07edae..a41522c1 100644
--- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs
+++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs
@@ -350,7 +350,9 @@ namespace YLErp.Modules.SwapModule
floatEvent.PositionId = position.PositionId;
floatEvent.EventType = (int)SwapEventTypeEnum.平仓;
floatEvent.EventReason = "交易";
- floatEvent.DividendIn = 0;
+ // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
+ // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
+ floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
@@ -561,7 +563,9 @@ namespace YLErp.Modules.SwapModule
if (position != null)
{
floatEvent.PositionId = position.PositionId;
- floatEvent.DividendIn = 0;
+ // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源),
+ // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。
+ floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate);
floatEvent.UnderlyingCode = position.UnderlyingCode;
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
@@ -949,6 +953,35 @@ namespace YLErp.Modules.SwapModule
return notionalValue > 0 ? posiNotionalValue / notionalValue : 1;
}
+ ///
+ /// 读取"上一收盘日"浮动腿的待实现分红(eod_swap_position.PosiDividendSum),
+ /// 用于平仓/互换预览页展示"浮动端平仓盈亏·分红(DividendIn)"。
+ /// 方案C:替代前端 totalInterest × 期初持仓 的重算——后者会把登记日前已平仓、
+ /// 不享有该笔分红的部分重复计入(GLMS-20260105-0004 误显 -36,160)。
+ /// EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"算出待实现分红,
+ /// 是单一可信源。
+ /// 复用 GetUnwindInterests(cs:624-626) 的"上一 EOD 日期"推导:取 eod_swap 中
+ /// ValueDate < dealDate 的最大日期,无则 dealDate.AddDays(-1);再经
+ /// SwapEodPositionService.GetPreEodPositions 取该日持仓,匹配 PositionId。
+ /// 抽为 protected virtual:与 GetMaxIncomeValueDate 一致,便于测试替身覆写、
+ /// 也兼容无 EOD 的边界(返回 0,与历史 DividendIn=0 行为一致)。
+ ///
+ /// 互换交易ID
+ /// 浮动腿持仓ID(swap_position.PositionId)
+ /// 预览/平仓业务日期
+ /// 上一收盘日该浮动腿的待实现分红;无 EOD 记录返回 0
+ protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
+ {
+ var lastEod = DbContext.eod_swap
+ .Where(x => x.ValueDate < dealDate && x.SwapTradeId == tradeId)
+ .OrderByDescending(o => o.ValueDate).FirstOrDefault();
+ var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate;
+ var preEod = new SwapEodPositionService(this)
+ .GetPreEodPositions(tradeId, preEodDate)
+ .FirstOrDefault(x => x.PositionId == positionId);
+ return preEod == null ? 0m : preEod.PosiDividendSum;
+ }
+
///
/// 获取固定利率
///
diff --git a/YLErpWeb/Controllers/BondPaymentController.cs b/YLErpWeb/Controllers/BondPaymentController.cs
index fa4f2ef2..f7368838 100644
--- a/YLErpWeb/Controllers/BondPaymentController.cs
+++ b/YLErpWeb/Controllers/BondPaymentController.cs
@@ -79,6 +79,10 @@ namespace YLErp.Web.Controllers
}
///
/// 获取某债券期间付息,扣除当天已消费部分
+ /// 【已废弃·不再用于平仓/互换预览】分红展示改由后端 SwapDealService.GetPreEodDividendSum
+ /// 读 EOD PosiDividendSum 提供(方案C,单一可信源)。前端 unwindSwapTrade.js / incomeSwapTrade.js
+ /// 的 getDivindIn 不再调用本接口。本接口仅保留供历史调用方,consumedDividend 查询无日期过滤的
+ /// 隐患随废弃自然消解,不再单独修复。
///
///
///
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index b52065cf..264da52b 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
@@ -266,18 +266,12 @@ const vue = new Vue({
},
getDivindIn() {
var thisObj = this;
- let ratio = this.floatPosition.PositionType == 1 ? 1 : -1;
- let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
- var postData = { startDate: thisObj.TradeStartDate, endDate: thisObj.deal.UnwindDate, underlyingCode: thisObj.floatPosition.UnderlyingCode, tradeId: thisObj.deal.SwapTradeId, unwindDate: thisObj.deal.UnwindDate }
- main.post("/BondPayment/GetBondPayMentInterest", postData, { async: false }).done(function (resp) {
- let totalDividend = parseFloat(thisObj.deal.NotionalQty) * resp.obj.totalInterest * ratio * floatRatio;//总的
- let consumedDividend = Math.abs(parseFloat(resp.obj.consumedDividend ?? 0)) * ratio * floatRatio;//已实现的
- // 互换是全量消费,consumedDividend>0 表示分红已被当天互换消费,归0
- thisObj.floatPosition.DividendIn = Math.abs(consumedDividend) > 0 ? parseFloat((totalDividend - consumedDividend).toFixed(2)) : parseFloat(totalDividend.toFixed(2));
- thisObj.floatPosition.DividendPending = 0;
- thisObj.calcFloatClosePnl();
- thisObj.dataFormat();
- });
+ // 方案C:分红改由后端 InitIncome 读 EOD PosiDividendSum 填入 floatPosition.DividendIn(单一可信源)。
+ // 前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓部分的重复计入。
+ // floatPosition.DividendIn 保持后端返回值不动;互换为全量消费,DividendPending=0。
+ thisObj.floatPosition.DividendPending = 0;
+ thisObj.calcFloatClosePnl();
+ thisObj.dataFormat();
},
incomeTrade() {//互换
var thisObj = this;
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
index 5afb4707..82db3722 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
@@ -387,27 +387,13 @@ const vue = new Vue({
},
getDivindIn() {
var thisObj = this;
- let ratio = this.floatPosition.PositionType == 1 ? 1 : -1;
- let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
- var postData = { startDate: thisObj.TradeStartDate, endDate: thisObj.deal.UnwindDate, underlyingCode: thisObj.floatPosition.UnderlyingCode, tradeId: thisObj.deal.SwapTradeId, unwindDate: thisObj.deal.UnwindDate }
- main.post("/BondPayment/GetBondPayMentInterest", postData, { async: false }).done(function (resp) {
- let consumedDividend = Math.abs(parseFloat(resp.obj.consumedDividend ?? 0)) * ratio * floatRatio;//已实现的
- let totalDividend = parseFloat(thisObj.deal.NotionalQty) * resp.obj.totalInterest * ratio * floatRatio;
- let remainDividend = totalDividend - consumedDividend;
- let dividendIn = 0;
- if (remainDividend != 0) {
- dividendIn = parseFloat(thisObj.deal.CloseQty) / parseFloat(thisObj.floatPosition.Quantity) * remainDividend
- }
- // 这里计算已经分红的利息,从totalInterest里扣除
-
- // 互换是全量消费,consumedDividend>0 表示分红已被当天互换消费,归0
- // 现在可能做了纯分红的互换结算,所以不能直接归0
- thisObj.floatPosition.DividendIn = parseFloat(dividendIn.toFixed(2));
- var posiQty = parseFloat(thisObj.floatPosition.Quantity) - parseFloat(thisObj.deal.CloseQty);
- thisObj.floatPosition.DividendPending = parseFloat((posiQty * resp.obj.totalInterest * ratio * floatRatio).toFixed(2));
- thisObj.calcFloatClosePnl();
- thisObj.dataFormat();
- });
+ // 方案C:分红改由后端 InitUnwind 读 EOD PosiDividendSum 填入 floatPosition.DividendIn(单一可信源)。
+ // 前端不再调用 GetBondPayMentInterest 自算——消除"期初持仓×totalInterest"对已平仓部分的重复计入
+ // (GLMS-20260105-0004 平仓前部分平仓40%后,再平仓时分红误显 -36,160,应为 0)。
+ // floatPosition.DividendIn 保持后端返回值不动;DividendPending(纯展示列)置 0。
+ thisObj.floatPosition.DividendPending = 0;
+ thisObj.calcFloatClosePnl();
+ thisObj.dataFormat();
},
closeTrade() {//平仓
var thisObj = this;