From aa1a13d7a30c0a45b14d0b3a82122ed55b48657b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Tue, 18 Aug 2026 11:20:56 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(trade):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E9=99=A4=E6=9D=83=E6=97=A5=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E5=9F=BA=E9=87=91=E5=85=AC=E5=8F=B8?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=A4=84=E7=90=86=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 EffectiveDate 字段用于标识真实除权生效日 - 实现公司行为价格系数和数量系数统一计算方法 - 增加基金除权回退和平仓基线恢复功能 - 完善除权日验证逻辑,确保真实除权日不早于股权登记日 - 重构平仓流程,支持按有效EOD基线重新计算损益和现金 - 添加基金拆合股和现金分红的特殊处理逻辑 - 增加单元测试验证各种公司行为场景下的正确性 --- ...undCorporateActionRollbackAndUnwindTest.cs | 326 +++++++++++++ .../SwapPositionComposeScenarioTest.cs | 328 ++++++++++++- .../SwapModule/TestableSwapDealService.cs | 34 ++ .../TestableSwapEodPositionService.cs | 22 + YLErpDAL/Model/ExDividendInfo.cs | 13 + .../Modules/SwapModule/SwapDealService.cs | 285 ++++++++++- .../SwapModule/SwapEodPositionService.cs | 446 +++++++++++++++++- .../Modules/SwapModule/SwapTradeService.cs | 19 +- .../TradeModule/DealModule/DividendService.cs | 109 ++++- .../Controllers/ex_dividend_infoController.cs | 14 +- .../app/underlying/underlyingDividendInfo.js | 4 + 11 files changed, 1582 insertions(+), 18 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs new file mode 100644 index 00000000..ed4f446b --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -0,0 +1,326 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class FundCorporateActionRollbackAndUnwindTest + { + private static readonly DateTime ExDate = new(2026, 8, 17); + + [TestMethod] + public void FCA_RB_001_回退选择最近实际Eod并遵守除权日边界() + { + var friday = CreateEod(new DateTime(2026, 8, 14), 1000m, 100m); + var exDate = CreateEod(ExDate, 2000m, 50m); + var invalidSunday = CreateEod(new DateTime(2026, 8, 16), 9999m, 1m); + invalidSunday.Invalid = true; + var snapshots = new[] { friday, invalidSunday, exDate }; + + var rollbackToExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate); + var rollbackAfterExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate.AddDays(1)); + + Assert.AreEqual(friday.ValueDate, rollbackToExDate.Single().ValueDate, + "回退到除权日应恢复除权前最近实际 EOD,不能用周日自然日或除权日自身"); + Assert.AreEqual(1000m, rollbackToExDate.Single().PosiQuantity); + Assert.AreEqual(exDate.ValueDate, rollbackAfterExDate.Single().ValueDate, + "回退到除权日之后应保留已经生效的除权 EOD"); + Assert.AreEqual(2000m, rollbackAfterExDate.Single().PosiQuantity); + } + + [TestMethod] + public void FCA_UW_001_最近FundEod恢复价格数量且重复恢复不重复除权() + { + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity); + Assert.AreEqual(50m, realtime.PosiGrossPrice); + Assert.AreEqual(100000m, realtime.PosiNotionalValue); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "恢复 EOD 是复制快照,不是再次套 10 送 10 系数,不能变成 4000"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "重复恢复不能把价格再次调整为 25"); + } + + [TestMethod] + public void FCA_UW_002_非Fund和最新Eod后已有完成流水时保持实时持仓() + { + var nonFund = CreateRealtimeFundPosition(); + nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + var eod = CreateEod(ExDate, 2000m, 50m); + + Assert.IsFalse(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); + Assert.AreEqual(1000m, nonFund.PosiQuantity); + Assert.AreEqual(100m, nonFund.PosiGrossPrice); + + var td = SwapDealTestFactory.CreateTrade(); + var realtime = CreateRealtimeFundPosition(); + realtime.PosiQuantity = 1500m; + realtime.PosiGrossPrice = 50m; + var service = CreateService(td, realtime, eod, hasCompletedFlow: true); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsFalse(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate.AddDays(1))); + Assert.AreEqual(1500m, realtime.PosiQuantity, + "EOD 后已有部分平仓流水时不能用 2000 份 EOD 覆盖实时剩余 1500 份"); + Assert.AreEqual(1000m, unwindData.CloseQty, + "未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程"); + } + + [TestMethod] + public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + + Assert.AreEqual(2000m, realtime.PosiQuantity, + "8 月 17 日盘中应先从 8 月 14 日 EOD 恢复,再按 10 送 10 变为 2000 份"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "真实除权生效日盘中应使用 50 元基准,不能继续使用登记日 100 元"); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + + [TestMethod] + public void FCA_UW_006_登记日盘中平仓不提前应用除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + // 8 月 14 日盘中尚未生成当日 EOD,最近可用快照应是 8 月 13 日。 + var eod = CreateEod(recordDate.AddDays(-1), 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = recordDate; + unwindData.UnwindDate = recordDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1000m, unwindData.PositionQty, + "登记日仍使用除权前 EOD 基线,不能提前变为 2000 份"); + Assert.AreEqual(1000m, unwindData.CloseQty); + Assert.AreEqual(100m, unwindData.FlowEvents.Single().PosiGrossPrice, + "登记日盘中平仓价格仍应为 100 元,除权生效日才切换为 50 元"); + } + + [TestMethod] + public void FCA_UW_007_基金直接拆合股比例零点零一_平仓按新数量价格() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + // 上游 splitratio=0.01 必须先转换为 10 * (0.01 - 1)=-9.9; + // 直接写 0.01 会按当前字段公式得到 1.001 倍,无法表达缩小为 0.01 倍。 + GiveShareAmount = -9.9m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(10m, unwindData.PositionQty, + "Fund splitratio=0.01 时,有效平仓基线应为 1000 * 0.01 = 10 份"); + Assert.AreEqual(10m, unwindData.CloseQty); + Assert.AreEqual(10000m, unwindData.FlowEvents.Single().PosiGrossPrice, + "Fund 份额缩小为 0.01 倍时,直接平仓期初价应为 100 / 0.01 = 10000"); + } + + [TestMethod] + public void FCA_UW_003_正式平仓按FundEod基线重算PnL和现金() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + var floatEvent = unwindData.FlowEvents.Single(); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(100000m, unwindData.CloseNotionalValue); + Assert.AreEqual(50m, floatEvent.PosiGrossPrice); + Assert.AreEqual(20000m, floatEvent.MarkClosePnl, + "平仓价 60 - 除权后期初价 50,乘 2000 份,应为 20000"); + Assert.AreEqual(20000m, unwindData.SwapRealizedPnL); + Assert.AreEqual(-20000d, service.ClientCashCalls.Single().amount, 0.001d, + "客户现金必须使用后台按有效 EOD 重算后的平仓金额"); + } + + [TestMethod] + public void FCA_UW_004_现金分红后部分平仓从Eod名义本金扣减() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 1000m, 99m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -500m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 0.5m, + closeQty: 500m, + closeNotionalValue: 50000m, + positionQty: 1000m); + unwindData.NotionalValue = 100000m; + unwindData.PosiNotionalValue = 100000m; + unwindData.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 99m, + Quantity = 500m, + PositionQty = 500m, + ContractSize = 1m, + MarkClosePnl = -500m + }); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(99000m, unwindData.PosiNotionalValue); + Assert.AreEqual(49500m, unwindData.CloseNotionalValue); + Assert.AreEqual(0m, unwindData.SwapRealizedPnL, + "市场价和除权后期初价同为 99 时不应产生额外盯市损益"); + Assert.AreEqual(49500d, td.StockEqvNotional, 0.001d, + "应从 EOD 有效名义本金 99000 扣除 49500,不能从旧 trade 值 100000 扣减"); + Assert.AreEqual(500d, td.TradeAmount, 0.001d); + } + + private static TestableSwapDealService CreateService( + trade td, + swap_position realtime, + eod_swap_position eod, + bool hasCompletedFlow) + { + return new TestableSwapDealService(td) + { + RealtimeFloatPosition = realtime, + LatestFundEodPosition = eod, + HasCompletedFlowAfterLatestFundEod = hasCompletedFlow, + ActiveSwapPositions = new List { realtime } + }; + } + + private static swap_position CreateRealtimeFundPosition() + { + return new swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + IsInitial = false, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + PosiNotionalValue = 100000m, + ContractSize = 1m + }; + } + + private static eod_swap_position CreateEod(DateTime valueDate, decimal quantity, decimal price) + { + return new eod_swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + ValueDate = valueDate, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNetPrice = price, + PosiNetFeePrice = price, + PosiNetNoFeePrice = price, + UnderlyingPrice = price, + PosiNotionalValue = quantity * price, + ContractSize = 1m + }; + } + + private static UnwindData CreateFullCloseUnwindData() + { + var data = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -40000m, + closeMethod: (int)CloseMethodEnum.全部平仓, + closePercent: 1m, + closeQty: 1000m, + closeNotionalValue: 100000m, + positionQty: 1000m); + data.NotionalValue = 100000m; + data.PosiNotionalValue = 100000m; + data.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 60m, + Quantity = 1000m, + PositionQty = 0m, + ContractSize = 1m, + MarkClosePnl = -40000m + }); + return data; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 3749384a..8f3b723f 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -57,6 +57,7 @@ namespace YLErp.Modules.SwapModule protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; protected override List FindCompletedFlowEvents(List tradeIds) => _flowEvents; + public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) => null; protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); protected override List FindSwapPositions(int swapTradeId) @@ -93,6 +94,20 @@ namespace YLErp.Modules.SwapModule public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); + + public void ExecuteFundCorporateActions( + IReadOnlyCollection positions, + IReadOnlyCollection previousEodPositions, + IReadOnlyCollection flowEvents, + IReadOnlyCollection dividendInfos) + { + ApplyFundCorporateActions( + positions, + previousEodPositions, + flowEvents, + dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), + SettleDate); + } } #endregion @@ -144,7 +159,7 @@ namespace YLErp.Modules.SwapModule PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, - UnderlyingCode = "220205.IB", ContractSize = 1m, + UnderlyingCode = "220205.IB", UnderlyingPrice = grossPrice, ContractSize = 1m, InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty }; } @@ -161,6 +176,41 @@ namespace YLErp.Modules.SwapModule }; } + private static ex_dividend_info CreateFundCorporateAction( + decimal cashAmount = 0m, + decimal shareAmount = 0m) + { + return new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = SettleDate, + EffectiveDate = SettleDate, + GiveCashAmount = cashAmount, + GiveShareAmount = shareAmount, + ValidStatus = true + }; + } + + private static void SetFundLeg(swap_position position, eod_swap_position previousEod) + { + position.UnderlyingCode = "FUND.TEST"; + position.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + position.PosiGrossPrice = 100m; + position.PosiNetPrice = 102m; + position.PosiNetFeePrice = 104m; + position.PosiNetNoFeePrice = 106m; + + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = position.UnderlyingInstrumentType; + previousEod.PosiGrossPrice = position.PosiGrossPrice; + previousEod.PosiNetPrice = position.PosiNetPrice; + previousEod.PosiNetFeePrice = position.PosiNetFeePrice; + previousEod.PosiNetNoFeePrice = position.PosiNetNoFeePrice; + previousEod.PosiNotionalValue = previousEod.PosiGrossPrice + * previousEod.PosiQuantity + * previousEod.ContractSize; + } + #endregion // ================================================================ @@ -238,6 +288,282 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); } + [TestMethod] + public void SPC_FUND_001_送股除权_调整价格数量并重算持仓结果() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(2000m, actual.PosiQuantity); + Assert.AreEqual(1000m, actual.TdChangedQty); + Assert.AreEqual(50m, actual.PosiGrossPrice); + Assert.AreEqual(51m, actual.PosiNetPrice); + Assert.AreEqual(52m, actual.PosiNetFeePrice); + Assert.AreEqual(53m, actual.PosiNetNoFeePrice); + Assert.AreEqual(100000m, actual.PosiNotionalValue); + Assert.AreEqual(200000m, actual.UnderlyingMarketValue); + Assert.AreEqual(100000m, actual.PosiMtmPnL); + Assert.AreEqual(100000m, actual.PosiProfitSum); + } + + [TestMethod] + public void SPC_FUND_002_现金分红_只调整价格不生成分红流水() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(0m, actual.TdChangedQty); + Assert.AreEqual(99m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdPosiDividend); + Assert.AreEqual(0m, actual.PosiDividendSum); + Assert.AreEqual(99000m, actual.PosiNotionalValue); + } + + [TestMethod] + public void SPC_FUND_003_同日重跑_从前日基线重算不重复除权() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var todayEod = previousEod.Clone(); + todayEod.ValueDate = SettleDate; + todayEod.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { todayEod }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + service.ExecuteFundCorporateActions( + new[] { todayEod }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(2000m, todayEod.PosiQuantity); + Assert.AreEqual(1000m, todayEod.TdChangedQty); + Assert.AreEqual(50m, todayEod.PosiGrossPrice); + Assert.AreEqual(100000m, todayEod.PosiNotionalValue); + } + + [TestMethod] + public void SPC_FUND_004_非Fund标的_即使命中公司行为也不调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + position.UnderlyingCode = "FUND.TEST"; + position.PosiGrossPrice = 100m; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = "TBonds"; + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdChangedQty); + } + + [TestMethod] + public void SPC_FUND_005_同日同代码多条有效记录_明确失败() + { + var service = new TestableSwapEodService( + new List { CreateTrade() }, + new List(), + new List(), + new List(), + new List { CreateExtend() }, + new List()); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 1m)); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 1m)); + + var exception = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + + StringAssert.Contains(exception.Message, "存在多条有效除权记录"); + } + + [TestMethod] + public void SPC_FUND_006_登记日Eod保持除权前数量价格_生效日才调整() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(recordDate, PreSettleDate); + + var recordEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1000m, recordEod.PosiQuantity, + "登记日 EOD 仍展示除权前数量,不能提前变成 2000"); + Assert.AreEqual(100m, recordEod.PosiGrossPrice, + "登记日 EOD 仍展示除权前价格,不能提前变成 50"); + } + + [TestMethod] + public void SPC_FUND_007_生效日先以除权后基线处理平仓_1000平300得到1700份50元() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var initialPosition = CreateFloatPosition(1, 1000m); + var realtimePosition = initialPosition.Clone(); + realtimePosition.id = 2; + realtimePosition.IsInitial = false; + realtimePosition.PositionId = initialPosition.id; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.ValueDate = recordDate; + SetFundLeg(initialPosition, previousEod); + SetFundLeg(realtimePosition, previousEod); + var closeFlow = CreateCloseFlowEvent(initialPosition.id, 300m); + closeFlow.UnderlyingCode = "FUND.TEST"; + closeFlow.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + closeFlow.DividendIn = 0m; + var service = new TestableSwapEodService( + new List { td }, + new List { initialPosition, realtimePosition }, + new List { previousEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = recordDate } }, + new List { CreateExtend() }, + new List { closeFlow }, + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(effectiveDate, recordDate); + + var effectiveEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1700m, effectiveEod.PosiQuantity, + "生效日先把 1000 份变为 2000 份,再平仓 300 份,应剩 1700 而非 1400"); + Assert.AreEqual(50m, effectiveEod.PosiGrossPrice, + "10 送 10 后期初价格应为 50"); + } + + [TestMethod] + public void SPC_FUND_008_上游splitratio零点零一映射GiveShareAmount负九点九_Eod数量价格调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + // 上游 splitratio=sharesafter/sharesbefore=0.01,落库前按 + // GiveShareAmount=10*(splitratio-1) 转换为 -9.9;现有公式因此得到 0.01 倍。 + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: -9.9m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(10m, actual.PosiQuantity, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,1000 份应调整为 10 份"); + Assert.AreEqual(10000m, actual.PosiGrossPrice, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,期初价格应反向放大 100 倍"); + } + // ================================================================ // 场景4:未收盘抛异常 // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 8b404f7e..6663355a 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -24,6 +24,13 @@ namespace YLErp.Modules.SwapModule public int SaveAllChangesCount; public int CloseReCheckCallCount; + /// Fund 盤中基线测试输入;生产服务通过数据库查询同名 seam。 + public swap_position RealtimeFloatPosition { get; set; } + public eod_swap_position LatestFundEodPosition { get; set; } + public bool HasCompletedFlowAfterLatestFundEod { get; set; } + public List ActiveSwapPositions { get; set; } = new(); + public List ExDividendInfos { get; } = new(); + public TestableSwapDealService(trade td, Dictionary swapEvents = null, Dictionary> flowEventsByEventId = null) @@ -36,6 +43,33 @@ namespace YLErp.Modules.SwapModule protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; + protected override List FindActiveSwapPositions(int tradeId) + => ActiveSwapPositions; + + protected override swap_position FindRealtimeFloatPosition(UnwindData unwindData) + => RealtimeFloatPosition; + + protected override eod_swap_position FindLatestFundEodPosition(int tradeId, long positionId, DateTime valueDate) + => LatestFundEodPosition; + + protected override bool HasCompletedFlowAfterFundEod(int tradeId, long positionId, DateTime eodDate, DateTime valueDate) + => HasCompletedFlowAfterLatestFundEod; + + protected override ex_dividend_info FindFundCorporateAction(string underlyingCode, DateTime valueDate) + => ExDividendInfos.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate == valueDate.Date); + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + + protected override decimal GetFundDividendTaxRate() => 0m; + + public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) + => TryRestoreEffectiveFundPosition(unwindData, valueDate); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action, valueDate)); diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 6539abe3..f034d731 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -42,6 +42,9 @@ namespace YLErp.Modules.SwapModule /// AddClientCash 调用记录(金额, 操作) public List<(double amount, string action)> ClientCashCalls { get; } = new(); + /// SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。 + public List ExDividendInfos { get; } = new(); + /// 自增 id 模拟器(新增 eod 时分配 id) private int _nextId = 1; @@ -78,6 +81,25 @@ namespace YLErp.Modules.SwapModule return 1.0; // 本币,汇率=1 } + protected override List FindExDividendInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date == settleDate.Date) + .ToList(); + } + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + + protected override decimal GetDividendTaxRate() + { + return 0m; + } + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action)); diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index bbf76a41..aa4b4c14 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -22,6 +22,14 @@ namespace YLErp.DBModels [DisplayName("股权登记日")] public DateTime? ExDividendDate { get; set; } + /// + /// 真实除权生效日。 + /// ExDividendDate 表示登记日,EffectiveDate 表示从哪个 EOD 起数量/价格基线 + /// 才允许切换到除权后口径;两者可能因周末、节假日或公告安排而不同。 + /// + [DisplayName("真实除权日")] + public DateTime? EffectiveDate { get; set; } + /// /// 税率 /// @@ -96,5 +104,10 @@ namespace YLErp.DBModels /// 股权登记日 /// public DateTime? ExDividendDate { get; set; } + + /// + /// 真实除权生效日。 + /// + public DateTime? EffectiveDate { get; set; } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 00f3419d..4a376890 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -243,6 +243,246 @@ namespace YLErp.Modules.SwapModule return td.ExerciseDate.Value.AddDays(-1); } + /// 查询交易当前有效的初始腿和实时腿。测试可返回内存快照,避免初始化测试触库。 + protected virtual List FindActiveSwapPositions(int tradeId) + { + return DbContext.swap_position + .Where(x => x.SwapTradeId == tradeId && !x.Invalid) + .ToList(); + } + + /// + /// 找到平仓数据对应的实时浮动腿。正式路径以 PositionId 绑定,缺失时才按标的代码兜底; + /// 这样后台不会把前端传入的价格当成权威基线。测试可 override 为内存持仓。 + /// + protected virtual swap_position FindRealtimeFloatPosition(UnwindData unwindData) + { + if (unwindData == null) + { + return null; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var query = DbContext.swap_position + .Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.IsInitial && !x.Invalid + && !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatEvent?.PositionId > 0) + { + var byPositionId = query.FirstOrDefault(x => x.PositionId == floatEvent.PositionId); + if (byPositionId != null) + { + return byPositionId; + } + } + if (!string.IsNullOrEmpty(floatEvent?.UnderlyingCode)) + { + var byCode = query.FirstOrDefault(x => x.UnderlyingCode == floatEvent.UnderlyingCode); + if (byCode != null) + { + return byCode; + } + } + return query.FirstOrDefault(); + } + + /// 查询 valueDate 当日已经生效的最近有效 Fund EOD。 + protected virtual eod_swap_position FindLatestFundEodPosition( + int tradeId, + long positionId, + DateTime valueDate) + { + return new SwapEodPositionService(this) + .GetLatestValidEodPosition(tradeId, positionId, valueDate); + } + + /// + /// 查询 valueDate 当天真正生效的 Fund 公司行为。 + /// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有 + /// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。 + /// + protected virtual ex_dividend_info FindFundCorporateAction( + string underlyingCode, + DateTime valueDate) + { + return DbContext.ex_dividend_info.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value == valueDate.Date); + } + + /// + /// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 + /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + /// 读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。 + protected virtual decimal GetFundDividendTaxRate() + => new DividendService(this).GetDividendTaxRateDecimal(); + + /// + /// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换, + /// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。 + /// + protected virtual bool HasCompletedFlowAfterFundEod( + int tradeId, + long positionId, + DateTime eodDate, + DateTime valueDate) + { + var asOfDate = valueDate.Date; + return DbContext.swap_flow_event.Any(x => x.SwapTradeId == tradeId + && x.PositionId == positionId + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate > eodDate + && x.EventDate <= asOfDate); + } + + /// + /// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。 + /// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50 + /// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。 + /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD + /// 和固定/利息腿均返回 false,沿用原逻辑。 + /// + protected virtual bool TryRestoreEffectiveFundPosition( + swap_position position, + DateTime valueDate) + { + // 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 + // 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值, + // 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。 + if (position == null + || position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + return false; + } + + var eodPosition = FindLatestFundEodPosition( + position.SwapTradeId, + position.PositionId, + valueDate); + if (eodPosition == null + || HasCompletedFlowAfterFundEod( + position.SwapTradeId, + position.PositionId, + eodPosition.ValueDate, + valueDate)) + { + return false; + } + + if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition)) + { + return false; + } + + // 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线, + // 不能再次套系数。只有“最近 EOD < EffectiveDate <= valueDate”时, + // 才在盘中恢复后补一次公司行为。 + var corporateAction = FindFundCorporateAction( + position.UnderlyingCode, + valueDate); + if (corporateAction?.EffectiveDate > eodPosition.ValueDate.Date + && corporateAction.EffectiveDate.Value.Date <= valueDate.Date) + { + var closePrice = GetFundCorporateActionClosePrice( + corporateAction, + position.PosiGrossPrice); + if (closePrice <= 0) + { + throw new ServiceException( + $"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + } + + var dividendTaxRate = GetFundDividendTaxRate(); + SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, + corporateAction, + closePrice, + dividendTaxRate); + } + + return true; + } + + /// + /// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金, + /// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。 + /// + protected virtual bool TryRestoreEffectiveFundPosition( + UnwindData unwindData, + DateTime valueDate) + { + var position = FindRealtimeFloatPosition(unwindData); + if (!TryRestoreEffectiveFundPosition(position, valueDate)) + { + return false; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var effectiveQty = position.PosiQuantity; + var requestedQty = unwindData.CloseQty; + var fullClose = unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓 + || unwindData.ClosePercent >= 1m; + // CloseQty 是部分平仓请求的数量口径;全平请求忽略前端缓存的旧数量,统一取 EOD 有效数量。 + // 例如 10 送 10 后 EOD 为 2000 份/50,前端仍传 1000 份时,全平必须落成 2000 份, + // 否则会遗留 1000 份;现金派现后若 EOD 名义本金为 99000,平一半应按 49500 扣减。 + // 若交易级余额仍沿用旧值 100000,再扣有效平仓额 49500,就会错误留下 50500。 + if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty)) + { + throw new ServiceException( + $"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); + } + + var closeQty = fullClose ? effectiveQty : requestedQty; + var closeNotional = fullClose + ? position.PosiNotionalValue + : Math.Round( + closeQty * position.PosiGrossPrice * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + unwindData.PositionQty = effectiveQty; + unwindData.PosiNotionalValue = position.PosiNotionalValue; + unwindData.CloseQty = closeQty; + unwindData.CloseNotionalValue = closeNotional; + if (!fullClose) + { + unwindData.ClosePercent = unwindData.NotionalValue > 0m + ? closeNotional / unwindData.NotionalValue + : (effectiveQty == 0m ? 0m : closeQty / effectiveQty); + } + + if (floatEvent != null) + { + floatEvent.PosiGrossPrice = position.PosiGrossPrice; + floatEvent.PosiNetPrice = position.PosiNetPrice; + floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; + floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice; + floatEvent.Quantity = closeQty; + floatEvent.PositionQty = effectiveQty - closeQty; + floatEvent.ContractSize = position.ContractSize; + + // EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。 + // FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。 + RecalculateNormalizedUnwindAmounts(unwindData); + } + return true; + } + #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) @@ -300,7 +540,7 @@ namespace YLErp.Modules.SwapModule public UnwindData InitUnwind(int tradeId) { var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = FindActiveSwapPositions(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType); List eventTyps = new List() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 }; @@ -310,6 +550,11 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); + // Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, + // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, + // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); swap_flow_event floatEvent = new swap_flow_event(); @@ -344,7 +589,11 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + // 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。 + // 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 + unwindData.PosiNotionalValue = restoredFundBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓; @@ -525,7 +774,7 @@ namespace YLErp.Modules.SwapModule { var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = FindActiveSwapPositions(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; var maxIncomeValueDate = GetMaxIncomeValueDate(td); @@ -534,6 +783,10 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD + // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes); var hasProcess = HasTradeProcess(); @@ -570,7 +823,9 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + unwindData.PosiNotionalValue = restoredFundBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; @@ -1574,6 +1829,18 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + // 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水), + // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 + if (restoredFundBaseline) + { + // 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。 + // 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500; + // 若仍从 trade 旧值 100000 扣减,会错误留下 50500。 + td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); + td.TradeAmount = Convert.ToDouble(unwindData.PositionQty); + } NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1635,6 +1902,8 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。 + // 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。 var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; @@ -2047,6 +2316,9 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照, + // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 + // 不会在这里回写,属于审批链路的残余风险。 ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -2083,6 +2355,9 @@ namespace YLErp.Modules.SwapModule { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } + // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。 + // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; + // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); NormalizeEventUnwindDate(swapEvent.unwindData); NormalizeNotionalValues(swapEvent.unwindData); @@ -2177,6 +2452,8 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。 + // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(unwindData, td); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index e445a357..aeddd68a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -14,6 +14,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.SwapModule.ReturnLegs; +using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -33,6 +34,118 @@ namespace YLErp.Modules.SwapModule } + /// + /// 在一组日终快照中选择严格早于指定日期的最近日。 + /// 回退到除权日 D 时必须得到 D 之前的基线;若使用 D 自身,除权后的 2000/50 + /// 会被当成除权前状态,重收盘时就可能再次套用 10 送 10。严格使用 < valueDate + /// 也覆盖周末、节假日:周一没有周日 EOD 时,直接选择上一个实际有快照的交易日。 + /// + public static DateTime? SelectLatestEodDateBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + if (eodPositions == null) + { + return null; + } + + var date = valueDate.Date; + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date < date) + .Select(x => (DateTime?)x.ValueDate.Date) + .OrderByDescending(x => x.Value) + .FirstOrDefault(); + } + + /// + /// 返回指定交易在 valueDate 之前最近实际 EOD 日的全部有效明细。 + /// 这是回退和纯单元测试共用的选择规则;调用方不得退化为 AddDays(-1),因为自然日 + /// 不等于交易日。若没有快照返回空集合,表示只能保留当前实时持仓,不能伪造基线。 + /// + public static List SelectLatestEodPositionsBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + var latestDate = SelectLatestEodDateBefore(eodPositions, valueDate); + if (!latestDate.HasValue) + { + return new List(); + } + + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date == latestDate.Value.Date) + .ToList(); + } + + /// + /// 从已经确认的 Fund EOD 快照恢复实时浮动腿的有效基线。 + /// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算 + /// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为 + /// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。 + /// 非 Fund、空快照或标的腿不满足收取方向时返回 false,保持原有逻辑。 + /// + public static bool RestoreFundPositionFromEod( + swap_position realtimePosition, + eod_swap_position eodPosition) + { + if (realtimePosition == null + || eodPosition == null + || realtimePosition.PosiDirection <= 0 + || realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + return false; + } + + realtimePosition.PosiQuantity = eodPosition.PosiQuantity; + realtimePosition.PosiGrossPrice = eodPosition.PosiGrossPrice; + realtimePosition.PosiNetPrice = eodPosition.PosiNetPrice; + realtimePosition.PosiNetFeePrice = eodPosition.PosiNetFeePrice; + realtimePosition.PosiNetNoFeePrice = eodPosition.PosiNetNoFeePrice; + realtimePosition.PosiNotionalValue = Math.Round( + eodPosition.PosiNotionalValue, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + realtimePosition.PosiTradingFeePending = eodPosition.PosiFeePending; + realtimePosition.PosiDividendIncome = eodPosition.PosiDividendSum; + return true; + } + + /// + /// 查询 valueDate 之前最近一份有效 Fund EOD 快照,作为盘中操作的日初基线。 + /// 必须严格使用 < valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据, + /// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50,D+1 盘中读取 D; + /// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。 + /// 调用方还需检查该 EOD 之后是否已有完成流水,避免覆盖当日部分平仓结果。 + /// + public virtual eod_swap_position GetLatestValidEodPosition( + int swapTradeId, + long positionId, + DateTime valueDate) + { + return DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId + && x.PositionId == positionId + && !x.Invalid + && x.ValueDate < valueDate.Date) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .FirstOrDefault(); + } + + /// + /// 回退/收益互换等路径需要的最近实际 EOD 快照集合;严格早于 valueDate,且过滤作废行。 + /// + public virtual List GetLatestEodPositionsBefore( + int swapTradeId, + DateTime valueDate) + { + var candidates = DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid && x.ValueDate < valueDate.Date) + .ToList(); + return SelectLatestEodPositionsBefore(candidates, valueDate); + } + private int GetStorageDeliveryPriceRound(string underlyingInstrumentType, string underlyingCode) { if (ConsGlobal.InstrumentType.IsBond(underlyingInstrumentType)) @@ -316,6 +429,51 @@ namespace YLErp.Modules.SwapModule .ToList(); } + /// + /// 查找结算日有效的公司行为记录;测试可替换为内存数据。 + /// settleDate 必须是收盘作业使用的日期边界(通常为 00:00:00),这里沿用完整 + /// DateTime 相等匹配;历史数据若带时分秒或为空,不会被静默归入当天,需在作业前 + /// 通过数据预检查处理,而不是让收盘在错误基线上继续计算。 + /// + protected virtual List FindExDividendInfos(DateTime settleDate) + { + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value == settleDate.Date) + .ToList(); + } + + /// + /// 获取公司行为公式使用的收盘价。 + /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 + /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日 + /// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + /// + /// 公司行为现金分红使用的系统税率,小数形式。 + /// 系统配置按百分数存储(例如 13 表示 13%),公司行为公式需要 0.13;送股本身 + /// 不受该税率影响;此处只负责读取并换算,不在异常时擅自默认为 0。 + /// + protected virtual decimal GetDividendTaxRate() + { + return new DividendService(this).GetDividendTaxRateDecimal(); + } + #endregion /// @@ -362,6 +520,20 @@ namespace YLErp.Modules.SwapModule var tradeExtendList = FindTradeExtends(tradeIds); var eodSwapList = FindEodSwapsByDate(preSettleDate); var completedFlowEvents = FindCompletedFlowEvents(tradeIds); + // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, + // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 + var exDividendInfos = FindExDividendInfos(settleDate); + var duplicateDividend = exDividendInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateDividend != null) + { + throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); + } + var exDividendByCode = exDividendInfos.ToDictionary( + x => x.UnderlyingCode, + x => x, + StringComparer.OrdinalIgnoreCase); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { @@ -401,8 +573,24 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 - //处理浮动腿 - var curEodPosis = DealFloatPositions(posiList, realPosiList, eodPositions, todyEodPositions, settleDate, td, preSettleDate, flowEvents); + // 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存 + // 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为 + // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 + // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 + // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + var openingEodPositions = PrepareFundOpeningEodPositions( + eodPositions, + exDividendByCode, + settleDate); + var curEodPosis = DealFloatPositions( + posiList, + realPosiList, + openingEodPositions, + todyEodPositions, + settleDate, + td, + preSettleDate, + flowEvents); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -432,6 +620,256 @@ namespace YLErp.Modules.SwapModule } } + + /// + /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Fund 公司行为。 + /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 + /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; + /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 + /// + protected List PrepareFundOpeningEodPositions( + IReadOnlyCollection previousEodPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + // 首日收盘或者当前非生效日 跳过 + if (previousEodPositions == null || previousEodPositions.Count == 0 + || exDividendByCode == null || exDividendByCode.Count == 0) + { + return previousEodPositions?.ToList() ?? new List(); + } + + // Clone 后只调整本次收盘的内存输入,不改 DbContext 跟踪的上一日实体; + // 否则重收盘或报表读取会把登记日的 Q/P 永久变成除权后 Q/P。 + var openingPositions = previousEodPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + // 应用公司行为 + ApplyFundCorporateActions( + openingPositions, + Array.Empty(), + Array.Empty(), + exDividendByCode, + settleDate); + return openingPositions; + } + + /// + /// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。 + /// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过 + /// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。 + /// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益, + /// 若同时再写 TdPosiDividend 会重复计入。 + /// + /// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50; + /// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50, + /// 不能直接在当日结果上再次计算成 4000/25。 + /// + /// + /// 有流水时不在这里强行恢复前一日数量,因为 DealFloatPositions 已把当日开平仓滚动到当前结果; + /// 盘中平仓不会再次套公式,而是读取严格早于 valueDate 的最近有效 EOD,必要时按当日 + /// EffectiveDate 再生成开盘基线。 + /// + /// + protected void ApplyFundCorporateActions( + IEnumerable positions, + IReadOnlyCollection previousEodPositions, + IReadOnlyCollection flowEvents, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + if (exDividendByCode.Count == 0) + { + return; + } + + // 获取系统税率 + var dividendTaxRate = GetDividendTaxRate(); + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo) + || !dividendInfo.EffectiveDate.HasValue + || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + { + continue; + } + + // 获取除权参考价 + var corporateActionClosePrice = GetFundCorporateActionClosePrice( + dividendInfo, + position.UnderlyingPrice); + if (corporateActionClosePrice <= 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + } + + // 当天已有 EOD 且该腿没有流水时,Copy 分支不会恢复价格字段。 + // 先还原前一日基线,避免同一结算日重跑时再次除权。 + var hasPositionFlow = flowEvents.Any(x => x.PositionId == position.PositionId); + var previousPosition = previousEodPositions.FirstOrDefault( + x => x.PositionId == position.PositionId); + // 没有流水 且 有前一日持仓时,恢复前一日价格字段 + if (!hasPositionFlow && previousPosition != null) + { + position.PosiQuantity = previousPosition.PosiQuantity; + position.PosiGrossPrice = previousPosition.PosiGrossPrice; + position.PosiNetPrice = previousPosition.PosiNetPrice; + position.PosiNetFeePrice = previousPosition.PosiNetFeePrice; + position.PosiNetNoFeePrice = previousPosition.PosiNetNoFeePrice; + } + + var denominator = 10m + dividendInfo.GiveShareAmount + dividendInfo.RationedSharesAmount; + if (denominator == 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); + } + + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + corporateActionClosePrice, + dividendTaxRate); + if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数"); + } + + // PriceRatio 是“除权前收盘价 / 除权参考价”,所以期初价格要除以它;ShareFactor + // 只来自送股/拆合股。10 送 10 时 1000 份/100 变为 2000 份/50,名义本金仍为 100000; + // 每 10 份派现 10 时数量不变、价格基准降为 99,名义本金变为 99000,后续平一半只能扣 49500。 + var originalQuantity = position.PosiQuantity; + position.PosiQuantity = Math.Round( + originalQuantity * factors.ShareFactor, + 12, + MidpointRounding.AwayFromZero); + position.TdChangedQty = position.PosiQuantity - originalQuantity; + + var storagePriceRound = GetStorageDeliveryPriceRound( + position.UnderlyingInstrumentType, + position.UnderlyingCode); + position.PosiGrossPrice = Math.Round( + position.PosiGrossPrice / factors.PriceRatio, + storagePriceRound, + MidpointRounding.AwayFromZero); + position.PosiNetPrice = Math.Round( + position.PosiNetPrice / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + if (position.PosiNetFeePrice.HasValue) + { + position.PosiNetFeePrice = Math.Round( + position.PosiNetFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + if (position.PosiNetNoFeePrice.HasValue) + { + position.PosiNetNoFeePrice = Math.Round( + position.PosiNetNoFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + + var shortRatio = DirectionRatio.LongShort(position.PositionType); + var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + position.UnderlyingMarketValue = MtmCalc.MarketValue( + position.UnderlyingPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio); + position.PosiMtmPnL = RoundMoney(MtmCalc.UnrealizedPnl( + position.UnderlyingPrice, + position.PosiGrossPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio, + directionRatio)); + position.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( + position.PosiMtmPnL, + position.PosiDividendSum, + position.PosiFeePending)); + position.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( + position.InterestProfitSum, + position.PosiProfitSum)); + position.PosiStatus = position.PosiQuantity == 0 ? 1 : 0; + } + } + + /// + /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 + /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 + /// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。 + /// 例:8 月 14 日 EOD 为 1000/100,8 月 17 日生效的 10 送 10 会得到 2000/50。 + /// + public static bool ApplyFundCorporateActionToPosition( + swap_position position, + ex_dividend_info dividendInfo, + decimal corporateActionClosePrice, + decimal dividendTaxRate) + { + if (position == null + || dividendInfo == null + || position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || corporateActionClosePrice <= 0) + { + return false; + } + + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + corporateActionClosePrice, + dividendTaxRate); + if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); + } + + var originalQuantity = position.PosiQuantity; + position.PosiQuantity = Math.Round( + originalQuantity * factors.ShareFactor, + 12, + MidpointRounding.AwayFromZero); + position.PosiGrossPrice = Math.Round( + position.PosiGrossPrice / factors.PriceRatio, + ConsGlobal.SwapDeliveryPriceRound, + MidpointRounding.AwayFromZero); + position.PosiNetPrice = Math.Round( + position.PosiNetPrice / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + if (position.PosiNetFeePrice.HasValue) + { + position.PosiNetFeePrice = Math.Round( + position.PosiNetFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + if (position.PosiNetNoFeePrice.HasValue) + { + position.PosiNetNoFeePrice = Math.Round( + position.PosiNetNoFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + return true; + } + /// /// 框架合约汇总 /// @@ -688,6 +1126,10 @@ namespace YLErp.Modules.SwapModule var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0); if (!hasDividend) return; + // ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金; + // 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入 + // PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。 + var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; if (dividendPayDateOffset <= 0) return; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 26a57cee..52e14f22 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1538,6 +1538,9 @@ namespace YLErp.Modules.SwapModule } else { + // 按日期回退时只恢复 valueDate 之前最近实际收盘的快照;当天及以后数据会在 + // InvalidTradeOptionDatasByDate 中清理。这样回退到除权日 D 会回到 D-1 的 + // 1000 份/100 基线并让重收盘重新应用公司行为;回退到 D+1 则保留 D 的 2000 份/50。 TradeBackByDate(td, valueDate, swapPositions); } if (swapEvent != null)//展期 @@ -1673,8 +1676,14 @@ namespace YLErp.Modules.SwapModule { SwapEodPositionService eodPositionService = new SwapEodPositionService(this); SwapDealService swapDealService = new SwapDealService(this); - var preDay = valueDate.AddDays(-1); - var eodSwapPositionList = DbContext.eod_swap_position.Where(x => x.ValueDate == preDay && x.SwapTradeId == td.id).ToList(); + // 回退基线必须是 valueDate 之前最近一个实际 EOD,而不是 valueDate-1 自然日。 + // 例如周一/节假日后的 valueDate 没有周日 EOD 时,AddDays(-1) 会得到空集合, + // 实时腿仍保留除权后的数量/价格。回退到除权日 D 选择 D 前基线并由下面的 + // InvalidTradeOptionDatasByDate 删除 D 及以后 EOD;回退到 D+1 则会选择 D, + // 保留 D 已生效的公司行为。ex_dividend_info 本身不删除,重收盘 D 会再应用一次。 + var eodSwapPositionList = eodPositionService.GetLatestEodPositionsBefore(td.id, valueDate); + // 没有 valueDate 之前的有效 EOD 时,eodSwapPositionList 为空;这是“没有可证明基线”的情况, + // 下面不会伪造数量/价格或重算公司行为,只保留当前实时持仓并继续清理回退日之后的数据。 var swapFlowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id && x.EventDate >= valueDate && x.DataState > (int)SwapFlowDateStateEnum.废弃).ToList(); var positions = swapPositions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList(); @@ -1700,6 +1709,8 @@ namespace YLErp.Modules.SwapModule td.TradeAmount = Convert.ToDouble(posi.PosiQuantity); } } + // eodPosi 为空时刻意不改 posi:回退只能使用已落库的历史快照,不能把缺失数据 + // 猜成 0 或交易初始值,否则会把未验证的 Fund 除权数量带入后续收盘。 } td.UnWindDate = null; td.UnWindNotional = null; @@ -1740,6 +1751,10 @@ namespace YLErp.Modules.SwapModule /// private void InvalidTradeOptionDatasByDate(int tradeId, DateTime valueDate, bool backToBegin) { + // 所有清理条件都采用闭区间起点 [valueDate, +∞):回退到除权日 D 要删除 D 当天 + // 已应用的 EOD/流水,随后重收盘 D 才会从 D-1 快照重新套一次系数;回退到 D+1 + // 不会删除 D,因而保留 D 已生效的 2000 份/50。valueDate 之前的快照始终保留, + // 作为 TradeBackByDate 的唯一可验证基线。 var swapEvents = DbContext.swap_event.Where(x => !x.Invalid && x.SwapTradeId == tradeId && x.ValueDate >= valueDate).ToList(); var swapEodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); var swapEods = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index ce9a9714..598b89c5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -20,6 +20,11 @@ namespace YLErp.Modules.TradeModule.DealModule } + public DividendService(YLBaseService baseService) : base(baseService) + { + + } + /// /// 场内交易除权除息 /// @@ -80,7 +85,9 @@ namespace YLErp.Modules.TradeModule.DealModule var dict = GetExDividendQuery(settleDate) .ToDictionary(K => K.UnderlyingId, V => V); var tradeIds = trades.Select(O => O.id); - var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); + var dividendRatioDict = new DbRecordChangesService(this) + .GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate) + .ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); foreach (var t in trades) { var bodTrade = new bod_trade(); @@ -102,7 +109,9 @@ namespace YLErp.Modules.TradeModule.DealModule { annualizeFactor = t.trade_snowball.AnnualizeFactor2; } - t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) : t.StockEqvNotionalReal; + t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 + ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) + : t.StockEqvNotionalReal; t.OriginalNotional = t.StockEqvNotionalReal / t.SpotPrice; t.TradeOriginalAmount = t.OriginalNotional / (t.CountRatio ?? 1); //不管是不是名义本金方式了结,都应该按照比例了结。--时嬴政 @@ -722,6 +731,7 @@ namespace YLErp.Modules.TradeModule.DealModule { return 0; } + // 价格 / 系数 var result = (decimal)price / decimalRatio; return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero); } @@ -736,14 +746,71 @@ namespace YLErp.Modules.TradeModule.DealModule return (double)GetRatioDecimal(info); } + internal readonly struct CorporateActionFactors + { + public CorporateActionFactors(decimal priceRatio, decimal shareFactor) + { + PriceRatio = priceRatio; + ShareFactor = shareFactor; + } + + public decimal PriceRatio { get; } + public decimal ShareFactor { get; } + } + + /// + /// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式; + /// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。 + /// + /// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50, + /// PriceRatio=100/50=2,ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为 + /// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。 + /// + /// + /// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99, + /// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。 + /// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。 + /// + /// + internal static CorporateActionFactors CalculateCorporateActionFactors( + ex_dividend_info info, + decimal closePrice, + decimal dividendRate) + { + // (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格) + // ------------------------------------------------------- + // (10 + 送股数量 + 配股数量) + var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate) + + info.RationedSharesAmount * info.RationedSharesPrice) + / (10m + info.GiveShareAmount + info.RationedSharesAmount); + var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice; + var shareFactor = 1m + info.GiveShareAmount / 10m; + return new CorporateActionFactors(priceRatio, shareFactor); + } + + /// + /// 将系统配置中的百分数税率转换为公司行为公式使用的小数税率。 + /// 例如配置 13 表示 13%,返回 0.13;送股系数不使用该税率,只有现金分红的税后金额使用。 + /// + internal decimal GetDividendTaxRateDecimal() + { + return (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + } + + /** + * 在没有现金分红和配股时: + * 拆合股:10*closePrice / 10+GiveShareAmount + * 调整后数量 = 原数量 × 除权系数 + * 调整后价格 = 原价格 ÷ 除权系数 + */ private decimal GetRatioDecimal(ex_dividend_info info) { - var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + // 除权系数依赖除权登记日收盘价;调用方若在收盘前或使用非标准日期调用, + // EodPriceProvider 可能拿不到价格并返回无效系数,不能把该情况默认为 1。 + var dividendRate = GetDividendTaxRateDecimal(); var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice); var decimalClosePrice = (decimal)closePrice; - var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / - (10m + info.GiveShareAmount + info.RationedSharesAmount); - return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice; + return CalculateCorporateActionFactors(info, decimalClosePrice, dividendRate).PriceRatio; } /// @@ -766,12 +833,16 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { + // 数量只按送股/拆合股调整,现金分红和配股不增加持仓数量;10 送 10 时 + // 1000 份变为 2000 份,价格系数由 GetRatioDecimal 单独计算,不能在此重复套用。 var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } public IQueryable GetExDividendQuery(DateTime valueDate) { + // 该查询沿用作业的“日期已归一化”约定,要求 valueDate 与存量 ExDividendDate + // 同为当天 00:00;自然日业务键的时分秒兼容由保存路径 FindExDividendByBusinessKey 负责。 return DbContext.ex_dividend_info .Where(O => O.ValidStatus && O.ExDividendDate == valueDate); } @@ -810,7 +881,9 @@ namespace YLErp.Modules.TradeModule.DealModule public void ImportDividendInfos(Stream stream) { var dt = new ExcelHelper().ExcelToDataTable(stream, null, true); - if (!dt.Columns.Contains("股票代码") || !dt.Columns.Contains("股权登记日")) + if (!dt.Columns.Contains("股票代码") + || !dt.Columns.Contains("股权登记日") + || !dt.Columns.Contains("真实除权日")) { throw new ServiceException("请使用正确的模板上传"); } @@ -821,6 +894,10 @@ namespace YLErp.Modules.TradeModule.DealModule { UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(), ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue, + EffectiveDate = DateTime.TryParse( + getColValueFromTable(dt.Rows[i], "真实除权日"), out var effectiveDate) + ? effectiveDate + : (DateTime?)null, GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, @@ -841,6 +918,14 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行股权登记日不正确"); } + if (!info.EffectiveDate.HasValue) + { + throw new ServiceException($"第{i + 1}行真实除权日不正确"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日"); + } dividendInfos.Add(info); } if (!AddDividendInfos(dividendInfos, out var errMsg)) @@ -900,6 +985,12 @@ namespace YLErp.Modules.TradeModule.DealModule { target.RationedSharesPrice = source.RationedSharesPrice; } + if (source.EffectiveDate.HasValue) + { + // EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。 + // 为空时不覆盖数据库已有值,避免旧记录在不完整导入中丢失真实生效日。 + target.EffectiveDate = source.EffectiveDate.Value.Date; + } } public bool AddDividendInfos(IEnumerable infos, out string errMsg) @@ -939,6 +1030,10 @@ namespace YLErp.Modules.TradeModule.DealModule // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 var exDividendDate = item.ExDividendDate.Value.Date; + if (item.EffectiveDate.HasValue) + { + item.EffectiveDate = item.EffectiveDate.Value.Date; + } var businessKey = (underlying.id, exDividendDate); if (item.id > 0 && recordKeys.TryGetValue(item.id, out var existingRecordKey) diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index a366b8ae..b5c7e0d4 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -46,14 +46,24 @@ namespace YLErp.Web.Controllers { throw new FormatException("未收到参数"); } - if (string.IsNullOrWhiteSpace(info.UnderlyingCode) || info.ExDividendDate == null) + if (string.IsNullOrWhiteSpace(info.UnderlyingCode) + || info.ExDividendDate == null + || info.EffectiveDate == null) { - throw new FormatException("标的代码或股权登记日信息不存在!"); + throw new FormatException("标的代码、股权登记日或真实除权日信息不存在!"); } if (QdpCalendarHelper.IsHoliday(info.ExDividendDate.Value)) { throw new FormatException("股权登记日不应为非交易日!"); } + if (QdpCalendarHelper.IsHoliday(info.EffectiveDate.Value)) + { + throw new FormatException("真实除权日不应为非交易日!"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new FormatException("真实除权日不应早于股权登记日!"); + } var status = new DividendService(CurUser).AddDividendInfos(new[] { info }, out var errMsg); if (!status) { diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index 8039367f..afee45e7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -18,6 +18,7 @@ function saveInfo(dataId, rowId) { id: dataId, UnderlyingCode: underlyingCode, ExDividendDate: $("#" + rowId + "_ExDividendDate").val(), + EffectiveDate: $("#" + rowId + "_EffectiveDate").val(), GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(), GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(), ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(), @@ -76,6 +77,7 @@ function gridComplete(obj) { id: 0, UnderlyingCode: underlyingCode, ExDividendDate: null, + EffectiveDate: null, GiveCashAmount: 0.0, GiveShareAmount: 0, RationedSharesAmount: 0, @@ -107,6 +109,8 @@ var colModelGrid = [{ name: 'EncryptId', label: 'EncryptId', index: 'EncryptId', width: 0, hidden: true }, { name: 'ExDividendDate', label: '股权登记日', index: 'ExDividendDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, +}, { + name: 'EffectiveDate', label: '真实除权日', index: 'EffectiveDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, }, { name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { From ddf233678d995caedf987571d30d6efe1d2aaa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Tue, 18 Aug 2026 15:43:11 +0800 Subject: [PATCH 02/31] =?UTF-8?q?feat(swap):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E9=87=91=E7=8E=B0=E9=87=91=E5=88=86=E7=BA=A2=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ApplyFundCashDividends 方法处理基金现金分红逻辑 - 现金分红在生效日 EOD 即执行,计入已实现分红字段 - 从盯市盈亏中分离分红金额,避免收益重复计算 - 更新测试用例验证现金分红正确记入已实现分红 - 修正测试方法名称反映实际功能变更 - 完善单元测试验证分红金额计算准确性 --- .../SwapPositionComposeScenarioTest.cs | 23 +++--- .../SwapModule/SwapEodPositionService.cs | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 8f3b723f..dbdc42db 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -107,6 +107,11 @@ namespace YLErp.Modules.SwapModule flowEvents, dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), SettleDate); + ApplyFundCashDividends( + positions, + previousEodPositions, + dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), + SettleDate); } } @@ -327,7 +332,7 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void SPC_FUND_002_现金分红_只调整价格不生成分红流水() + public void SPC_FUND_002_现金分红_收盘时记入已实现分红() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); @@ -342,22 +347,20 @@ namespace YLErp.Modules.SwapModule new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); - var actual = previousEod.Clone(); - actual.ValueDate = SettleDate; - actual.UnderlyingPrice = 100m; + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); - service.ExecuteFundCorporateActions( - new[] { actual }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); + var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(0m, actual.TdChangedQty); Assert.AreEqual(99m, actual.PosiGrossPrice); - Assert.AreEqual(0m, actual.TdPosiDividend); + Assert.AreEqual(1000m, actual.TdPosiDividend); Assert.AreEqual(0m, actual.PosiDividendSum); Assert.AreEqual(99000m, actual.PosiNotionalValue); + Assert.AreEqual(0m, actual.PosiMtmPnL); + Assert.AreEqual(0m, actual.PosiProfitSum); + Assert.AreEqual(1000m, actual.RealizedDividend); + Assert.AreEqual(1000m, actual.RealizedPnl); } [TestMethod] diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index aeddd68a..0fb9afa3 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -591,6 +591,15 @@ namespace YLErp.Modules.SwapModule td, preSettleDate, flowEvents); + // 公司行为 - 分红 + // Fund 现金分红在 EffectiveDate 当日收盘即完成结算: + // TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额, + // 不把同一笔金额留在 PosiDividendSum 待实现字段中。 + ApplyFundCashDividends( + curEodPosis, + eodPositions, + exDividendByCode, + settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -805,6 +814,74 @@ namespace YLErp.Modules.SwapModule } } + /// + /// 将 Fund 当日现金分红记入 EOD 已实现分红。 + /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 + /// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额, + /// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。 + /// + protected void ApplyFundCashDividends( + IReadOnlyCollection currentEodPositions, + IReadOnlyCollection previousEodPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + if (currentEodPositions == null + || previousEodPositions == null + || exDividendByCode == null + || exDividendByCode.Count == 0) + { + return; + } + + var dividendTaxRate = GetDividendTaxRate(); + foreach (var current in currentEodPositions) + { + if (current == null + || current.PosiDirection == 0 + || current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || string.IsNullOrWhiteSpace(current.UnderlyingCode) + || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) + || !dividendInfo.EffectiveDate.HasValue + || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + { + continue; + } + + var previous = previousEodPositions.FirstOrDefault( + x => x != null && x.PositionId == current.PositionId); + var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity; + var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection); + var currentDividend = entitlementQuantity > 0m + ? entitlementQuantity / 10m + * dividendInfo.GiveCashAmount + * (1m - dividendTaxRate) + * directionRatio + : 0m; + + // 当日浮动端分红 + current.TdPosiDividend = RoundMoney(currentDividend); + var previousDividendSum = previous?.PosiDividendSum ?? 0m; + // 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红 + current.PosiDividendSum = current.PosiQuantity > 0m + ? RoundMoney(previousDividendSum - current.TdCloseDividend) + : 0m; + // 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红 + // current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend); + // 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红 + current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend); + // 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红 + current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( + current.PosiMtmPnL, + current.PosiDividendSum, + current.PosiFeePending)); + SetFloatingRealizedPnl(current); + current.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( + current.InterestProfitSum, + current.PosiProfitSum)); + } + } + /// /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 From aa3548e77f45fa2ea2fe08ad242a7a7bd108746b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Wed, 19 Aug 2026 17:48:34 +0800 Subject: [PATCH 03/31] =?UTF-8?q?feat(bond):=20=E6=94=AF=E6=8C=81=E5=80=BA?= =?UTF-8?q?=E5=88=B8=E4=B8=8E=E8=82=A1=E7=A5=A8=E5=9F=BA=E9=87=91=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=E8=A1=8C=E4=B8=BA=E7=8E=B0=E9=87=91=E6=B5=81=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E7=9A=84=E5=B7=AE=E5=BC=82=E5=8C=96=E5=A4=84=E7=90=86?= =?UTF-8?q?=20-=20init2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径 - 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算 - 在BondPaymentService中添加详细的参数说明文档注释 - 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式 - 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理 - 添加SplitCorporateActionTddTest测试验证拆合股功能 - 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试 - 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息 --- .../DBModels/Enums/SwapEventTypeEnum.cs | 3 +- Framework/YLErp.Core/DBModels/SwapEvent.cs | 31 ++ .../SwapModule/BondTrsAutoSwapScenarioTest.cs | 15 +- .../CorporateActionEventLifecycleTest.cs | 303 +++++++++++ ...undCorporateActionRollbackAndUnwindTest.cs | 10 +- .../SwapModule/SplitCorporateActionTddTest.cs | 197 +++++++ .../SwapPositionComposeScenarioTest.cs | 14 +- .../TestableSwapEodPositionService.cs | 29 + YLErpDAL/Model/ExDividendInfo.cs | 7 + .../Modules/EodModule/BondPaymentService.cs | 37 +- .../Modules/SwapModule/SwapDealService.cs | 16 +- .../SwapModule/SwapEodPositionService.cs | 500 ++++++++++++++++-- .../Modules/SwapModule/SwapEventService.cs | 113 +++- .../Modules/SwapModule/SwapTradeService.cs | 25 +- .../TradeModule/DealModule/DividendService.cs | 132 ++++- .../App_Docs/导入模板/个股除权信息模板.xlsx | Bin 11261 -> 7018 bytes .../Controllers/ex_dividend_infoController.cs | 4 + .../Views/SwapTrade2/OperationHistory.cshtml | 6 +- .../app/underlying/underlyingDividendInfo.js | 4 + 19 files changed, 1342 insertions(+), 104 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs diff --git a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs index 95d6fc27..5f607554 100644 --- a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs @@ -20,6 +20,7 @@ namespace YLErp.DBModels 确认交易=9, 审批通过=10, 审批拒绝=11, - 删除=12 + 删除=12, + 公司行为=13 } } diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index eff65bac..9501c4e4 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -82,6 +82,37 @@ namespace YLErp.DBModels [NotMapped] public UnwindData unwindData { get; set; } } + + /// + /// 公司行为事件快照。登记日先写入待生效快照,真实除权日补齐调整后数据; + /// 已应用快照只允许追加回退事件,不覆盖原记录。 + /// ExDividendDate 是登记日,EffectiveDate 是 Q/P 真实切换日;GiveShareAmount + /// 表示每 10 份增减数量,Split 表示独立拆/合股倍数(null 按 1)。Before/After + /// 分别保存调整前后名义本金、价格、数量和待实现分红,CashFlowChange 保存现金变化。 + /// + public class CorporateActionEventData + { + public int ExDividendInfoId { get; set; } + public long PositionId { get; set; } + public string UnderlyingCode { get; set; } + public DateTime? ExDividendDate { get; set; } + public DateTime? EffectiveDate { get; set; } + public decimal GiveCashAmount { get; set; } + public decimal GiveShareAmount { get; set; } + public decimal? Split { get; set; } + public decimal RationedSharesAmount { get; set; } + public decimal RationedSharesPrice { get; set; } + public decimal BeforeNotional { get; set; } + public decimal BeforePrice { get; set; } + public decimal BeforeQuantity { get; set; } + public decimal AfterNotional { get; set; } + public decimal AfterPrice { get; set; } + public decimal AfterQuantity { get; set; } + public decimal BeforePendingDividend { get; set; } + public decimal AfterPendingDividend { get; set; } + public decimal CashFlowChange { get; set; } + public bool Applied { get; set; } + } /// /// 展期信息 /// diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index e9b542c8..baf4b420 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -69,7 +69,10 @@ namespace YLErp.Modules.SwapModule public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new(); /// 捕获落库的互换流水明细 - public List PersistedFlowEvents { get; } = new(); + public List PersistedFlowEvents => DbContext.swap_flow_event.Local.ToList(); + + /// 捕获资金流水的金额、操作类型和发生日 + public List<(double amount, string action, DateTime valueDate)> ClientCashCallDetails { get; } = new(); public AutoSwapEodService( List trades, List positions, @@ -112,6 +115,13 @@ namespace YLErp.Modules.SwapModule protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + ClientCashCallDetails.Add((amount, action, valueDate)); + return ClientCashCalls.Count; + } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) { @@ -509,6 +519,9 @@ namespace YLErp.Modules.SwapModule $"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})"); Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate), $"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日"); + Assert.AreEqual(1, svc.ClientCashCallDetails.Count, "应生成 1 条分红资金流水"); + Assert.AreEqual(actualPayDate, svc.ClientCashCallDetails[0].valueDate, + "资金发生日应使用分红支付日"); } // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs new file mode 100644 index 00000000..7e755187 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.DBModels.Consts; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class CorporateActionEventLifecycleTest + { + // 8/14 登记日只创建 Applied=false 的待生效事件;8/17 真实生效日补齐 + // 同一事件的调整前后快照并标记 Applied=true。 + private static readonly DateTime RecordDate = new DateTime(2026, 8, 14); + private static readonly DateTime EffectiveDate = new DateTime(2026, 8, 17); + + [TestMethod] + public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields() + { + var info = CreateAction(77, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + null, + applied: false); + + Assert.AreEqual(77, snapshot.ExDividendInfoId); + Assert.AreEqual(9L, snapshot.PositionId); + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(100000m, snapshot.BeforeNotional); + Assert.AreEqual(0m, snapshot.AfterQuantity); + Assert.IsFalse(snapshot.Applied); + + var reason = SwapEventService.BuildCorporateActionEventReason(snapshot); + StringAssert.Contains(reason, "BeforeQuantity=1000"); + StringAssert.Contains(reason, "AfterQuantity=0"); + } + + [TestMethod] + public void EffectiveSnapshot_ContainsAfterFields_AndSupportsStockAndFund() + { + var info = CreateAction(78, ConsGlobal.InstrumentType.Fund); + var before = CreateEodPosition(10, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(10, info.UnderlyingCode, 2000m, 50m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + after, + applied: true); + + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(2000m, snapshot.AfterQuantity); + Assert.AreEqual(50m, snapshot.AfterPrice); + Assert.AreEqual(100000m, snapshot.AfterNotional); + Assert.IsTrue(snapshot.Applied); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Stock)); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Fund)); + Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds)); + } + + [TestMethod] + public void Rerun_DoesNotCreateDuplicateCorporateActionEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var existing = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(snapshot), + Invalid = false + }; + + Assert.IsFalse(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { existing }, + info, + 11L)); + } + + [TestMethod] + public void LegacyEventWithoutExDividendInfoId_DoesNotBlockCurrentEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var legacySnapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + legacySnapshot.ExDividendInfoId = 0; + var legacyEvent = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(legacySnapshot), + Invalid = false + }; + + Assert.IsTrue(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { legacyEvent }, + info, + 11L)); + } + + [TestMethod] + public void OperationHistory_FiltersPendingCorporateActionOnly() + { + var info = CreateAction(80, ConsGlobal.InstrumentType.Stock); + var pendingData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(12, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var appliedData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(13, info.UnderlyingCode, 1000m, 100m), + CreateEodPosition(13, info.UnderlyingCode, 2000m, 50m), + applied: true); + var events = new List + { + new swap_event { id = 1, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(pendingData) }, + new swap_event { id = 2, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(appliedData) }, + new swap_event { id = 3, EventType = (int)SwapEventTypeEnum.互换, EventData = "{}" } + }; + + var visible = SwapEventService.FilterOperationHistory(events); + + Assert.AreEqual(2, visible.Count); + CollectionAssert.DoesNotContain(visible.Select(x => x.id).ToList(), 1L); + CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 2L); + CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 3L); + } + + [TestMethod] + public void EffectiveCorporateAction_AdjustsStockQuantityAndPrice() + { + var position = new swap_position + { + PositionId = 14, + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + UnderlyingCode = "STOCK.TEST", + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + ContractSize = 1m + }; + var info = CreateAction(81, ConsGlobal.InstrumentType.Stock); + info.GiveShareAmount = 10m; + + var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, + info, + 100m, + 0m); + + Assert.IsTrue(applied); + Assert.AreEqual(2000m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + Assert.AreEqual(100000m, position.PosiNotionalValue); + } + + [TestMethod] + public void Lifecycle_RegistrationIsIdempotent_ThenEffectiveUpdatesSameEvent() + { + var info = CreateAction(82, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(15, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(15, info.UnderlyingCode, 2000m, 50m); + var service = new EventRecordingService(); + var trade = new trade { id = 100 }; + + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + + Assert.AreEqual(1, service.Events.Count); + Assert.AreEqual(1000m, before.PosiQuantity, "登记日不能改持仓数量"); + Assert.AreEqual(100m, before.PosiGrossPrice, "登记日不能改持仓价格"); + var pending = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsFalse(pending.Applied); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + + service.Record( + trade, + new[] { after }, + new[] { before }, + Array.Empty(), + new[] { info }, + EffectiveDate); + + Assert.AreEqual(1, service.Events.Count, "生效日应更新原事件而非新增事件"); + Assert.AreEqual(1, service.UpdateCount); + var applied = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsTrue(applied.Applied); + Assert.AreEqual(1000m, applied.BeforeQuantity); + Assert.AreEqual(2000m, applied.AfterQuantity); + Assert.AreEqual(50m, applied.AfterPrice); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + } + + private sealed class EventRecordingService : TestableSwapEodPositionService + { + public List Events { get; } = new List(); + public int UpdateCount { get; private set; } + + public EventRecordingService() + : base(nameof(CorporateActionEventLifecycleTest)) + { + } + + protected override List FindCorporateActionEvents(int swapTradeId) + { + return Events; + } + + protected override swap_event AddSwapEvent( + DateTime tradeDate, + int swapTradeId, + int eventType, + string data, + int clientCashId, + bool save, + string reason) + { + return new swap_event { id = Events.Count + 1 }; + } + + protected override void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + UpdateCount++; + } + + public void Record( + trade trade, + IReadOnlyCollection current, + IReadOnlyCollection previous, + IReadOnlyCollection registration, + IReadOnlyCollection effective, + DateTime settleDate) + { + RecordCorporateActionEvents( + trade, + current, + previous, + registration, + effective, + settleDate); + } + } + + private static ex_dividend_info CreateAction(int id, string instrumentType) + { + return new ex_dividend_info + { + id = id, + UnderlyingCode = instrumentType == ConsGlobal.InstrumentType.Fund ? "FUND.TEST" : "STOCK.TEST", + ExDividendDate = RecordDate, + EffectiveDate = EffectiveDate, + GiveShareAmount = 0m, + GiveCashAmount = 0m, + ValidStatus = true + }; + } + + private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price) + { + return new eod_swap_position + { + PositionId = positionId, + UnderlyingCode = code, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNotionalValue = quantity * price, + PosiNetPrice = price, + ContractSize = 1m, + PosiDirection = 1, + PositionType = 1 + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs index ed4f446b..8531e661 100644 --- a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -6,6 +6,8 @@ namespace YLErp.Modules.SwapModule [TestClass] public class FundCorporateActionRollbackAndUnwindTest { + // 生产恢复范围已从原 Fund-only 扩展到 TRS Fund/Stock;本组继续使用 Fund 夹具, + // 验证共享的登记日/EffectiveDate 边界和回退、平仓基线。 private static readonly DateTime ExDate = new(2026, 8, 17); [TestMethod] @@ -51,15 +53,15 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void FCA_UW_002_非Fund和最新Eod后已有完成流水时保持实时持仓() + public void FCA_UW_002_股票与最新Eod后已有完成流水时保持实时持仓() { var nonFund = CreateRealtimeFundPosition(); nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; var eod = CreateEod(ExDate, 2000m, 50m); - Assert.IsFalse(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); - Assert.AreEqual(1000m, nonFund.PosiQuantity); - Assert.AreEqual(100m, nonFund.PosiGrossPrice); + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); + Assert.AreEqual(2000m, nonFund.PosiQuantity); + Assert.AreEqual(50m, nonFund.PosiGrossPrice); var td = SwapDealTestFactory.CreateTrade(); var realtime = CreateRealtimeFundPosition(); diff --git a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs new file mode 100644 index 00000000..022ad4df --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class SplitCorporateActionTddTest + { + [TestMethod] + public void SplitTenScalesQuantityAndPriceByTen() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(1000m, position.PosiQuantity); + Assert.AreEqual(10m, position.PosiGrossPrice); + } + + [TestMethod] + public void SplitPointOneScalesQuantityAndPriceByPointOne() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 0.1m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(10m, position.PosiQuantity); + Assert.AreEqual(1000m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareTenWithNullSplitUsesCompatibleFactorTwo() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 10m, split: null); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(200m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareFiveAndSplitTwoHaveCombinedFactorThree() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 5m, split: 2m); + + // (1 + 5 / 10) * 2 = 3:100 份/100 元变为 300 份/约 33.333333333 元。 + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(300m, position.PosiQuantity); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 33.333333333m) < 0.000000001m); + } + + [TestMethod] + public void CashAmountDoesNotChangeTrsFundInitialPriceFactor() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(cash: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(100m, position.PosiQuantity); + Assert.AreEqual(100m, position.PosiGrossPrice); + } + + [TestMethod] + public void RationedSharesUseExcelPriceRatioForTrsQuantity() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction( + rationedSharesAmount: 1m, + rationedSharesPrice: 50m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + // Excel L-N:L=(100*10+1*50)/(10+1)=95.4545...,M=100/L; + // 因此数量和价格分别按 Q'=Q*M、P'=P/M 调整。 + Assert.IsTrue(Math.Abs(position.PosiQuantity - 104.761904761905m) < 0.000000000001m); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 95.454545455m) < 0.000000001m); + } + + [TestMethod] + public void ZeroSplitIsRejected() + { + var info = CreateCorporateAction(split: 0m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyFundCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void NegativeSplitIsRejected() + { + var info = CreateCorporateAction(split: -1m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyFundCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void MissingSplitDoesNotClearExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: null); + + InvokeMerge(target, source); + + Assert.AreEqual(10m, GetSplit(target)); + } + + [TestMethod] + public void ExplicitSplitOneOverridesExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: 1m); + + InvokeMerge(target, source); + + Assert.AreEqual(1m, GetSplit(target)); + } + + private static ex_dividend_info CreateCorporateAction( + decimal cash = 0m, + decimal giveShare = 0m, + decimal? split = null, + decimal rationedSharesAmount = 0m, + decimal rationedSharesPrice = 0m) + { + var info = new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = new DateTime(2026, 8, 14), + EffectiveDate = new DateTime(2026, 8, 17), + GiveCashAmount = cash, + GiveShareAmount = giveShare, + RationedSharesAmount = rationedSharesAmount, + RationedSharesPrice = rationedSharesPrice, + ValidStatus = true + }; + SetSplit(info, split); + return info; + } + + private static swap_position CreateFundPosition() + { + return new swap_position + { + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + UnderlyingCode = "FUND.TEST", + PosiQuantity = 100m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + ContractSize = 1m + }; + } + + private static void SetSplit(ex_dividend_info info, decimal? value) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + property.SetValue(info, value); + } + + private static decimal? GetSplit(ex_dividend_info info) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + return (decimal?)property.GetValue(info); + } + + private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source) + { + var method = typeof(DividendService).GetMethod( + "MergeNonZeroDividendValues", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(method, "公司行为存量合并方法不存在"); + method.Invoke(null, new object[] { target, source }); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index dbdc42db..9b8e5677 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -332,7 +332,7 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void SPC_FUND_002_现金分红_收盘时记入已实现分红() + public void SPC_FUND_002_现金分红_登记日不直接入账() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); @@ -351,16 +351,18 @@ namespace YLErp.Modules.SwapModule var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); + // 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息 + // 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。 Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(0m, actual.TdChangedQty); - Assert.AreEqual(99m, actual.PosiGrossPrice); - Assert.AreEqual(1000m, actual.TdPosiDividend); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdPosiDividend); Assert.AreEqual(0m, actual.PosiDividendSum); - Assert.AreEqual(99000m, actual.PosiNotionalValue); + Assert.AreEqual(100000m, actual.PosiNotionalValue); Assert.AreEqual(0m, actual.PosiMtmPnL); Assert.AreEqual(0m, actual.PosiProfitSum); - Assert.AreEqual(1000m, actual.RealizedDividend); - Assert.AreEqual(1000m, actual.RealizedPnl); + Assert.AreEqual(0m, actual.RealizedDividend); + Assert.AreEqual(0m, actual.RealizedPnl); } [TestMethod] diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index f034d731..1809123e 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -45,6 +45,9 @@ namespace YLErp.Modules.SwapModule /// SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。 public List ExDividendInfos { get; } = new(); + /// 捕获公司行为生命周期事件,避免事件测试访问真实 swap_event 表。 + public List CorporateActionEvents { get; } = new(); + /// 自增 id 模拟器(新增 eod 时分配 id) private int _nextId = 1; @@ -90,6 +93,32 @@ namespace YLErp.Modules.SwapModule .ToList(); } + protected override List FindCorporateActionInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && (x.ExDividendDate?.Date == settleDate.Date + || x.EffectiveDate?.Date == settleDate.Date)) + .ToList(); + } + + protected override List FindRegistrationExDividendInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && x.ExDividendDate.HasValue + && x.ExDividendDate.Value.Date == settleDate.Date) + .ToList(); + } + + protected override List FindCorporateActionEvents(int swapTradeId) + { + return CorporateActionEvents + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid + && x.EventType == (int)SwapEventTypeEnum.公司行为) + .ToList(); + } + protected override decimal GetFundCorporateActionClosePrice( ex_dividend_info dividendInfo, decimal fallbackPrice) diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index aa4b4c14..3e5305ff 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -56,6 +56,13 @@ namespace YLErp.DBModels [DisplayName("送股股数")] public decimal GiveShareAmount { get; set; } + + /// + /// 拆/合股倍数。为空时按 1 兼容历史记录;与 GiveShareAmount 的“每 10 份送股数量”语义不同。 + /// + [DisplayName("拆/合股倍数")] + public decimal? Split { get; set; } + /// /// 配股手数 /// diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index a10caab3..8661bdee 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -119,24 +119,49 @@ namespace YLErp.Modules.EodModule /// 多空方向 /// 收支方向 /// - public decimal CalcPayment(string underlyingCode, DateTime startDate, DateTime endDate, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + string underlyingCode, + DateTime startDate, + DateTime endDate, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var payments = GetBondPayments(underlyingCode, startDate, endDate); - return CalcPayment(payments, qty, longRatio, payDirection); + return CalcPayment(payments, qty, longRatio, payDirection, useBondPriceScale); } /// - /// 计算某债券期间付息 + /// 计算某标的期间现金流。债券与 Stock/Fund 公司行为共用 bond_payment_info, + /// 但通过 useBondPriceScale 明确区分两种入库金额单位。 /// /// 期间付息集合 /// 持仓数量 /// 多空方向 /// 收支方向 + /// + /// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息, + /// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红 + /// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金, + /// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 + /// /// - public decimal CalcPayment(List payments, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + List payments, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var interest = payments.Sum(s => s.payment_interest ?? 0); - // interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额(与入库价格 bondPriceMultiple 同口径) - return BondPriceConverter.ToStorage(interest * qty) * longRatio * payDirection; + var paymentAmount = interest * qty; + // 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。 + // Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10, + // ×qty 就是“每 10 份派现额 × 持仓份额”,必须保留原金额,不能套债券的 /100。 + var actualAmount = useBondPriceScale + ? BondPriceConverter.ToStorage(paymentAmount) + : paymentAmount; + return actualAmount * longRatio * payDirection; } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 4a376890..be443144 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -2005,10 +2005,20 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); - // + 付息日>上日日终且小于等于平仓日期的分红数据 - var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); - decimal tax = um.ValueAddedTax ?? 0; + // 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/Fund 的公司行为 + // 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。 + // 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。 + var useBondPriceScale = um == null + || !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType); + // + 付息日>上日日终且小于等于平仓日期的分红数据 + var dividendIn = servie.CalcPayment( + payments, + unwindQty, + shortRatio, + directionRatio, + useBondPriceScale); + decimal tax = um?.ValueAddedTax ?? 0; dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 0fb9afa3..af6b23ad 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -91,8 +91,8 @@ namespace YLErp.Modules.SwapModule if (realtimePosition == null || eodPosition == null || realtimePosition.PosiDirection <= 0 - || realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund - || eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + || !IsTrsCorporateActionInstrument(realtimePosition.UnderlyingInstrumentType) + || !IsTrsCorporateActionInstrument(eodPosition.UnderlyingInstrumentType)) { return false; } @@ -370,10 +370,27 @@ namespace YLErp.Modules.SwapModule return DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); } - /// 计算债券付息(生产: BondPaymentService;测试: 返回固定值) + /// + /// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。 + /// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为 + /// 分红行按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。 + /// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍。 + /// protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { - return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + var underlying = GetUnderlyingData(underlyingCode); + // 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于 + // 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。 + var useBondPriceScale = underlying == null + || !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType); + return new BondPaymentService(UserInfo).CalcPayment( + underlyingCode, + fromDate, + toDate, + qty, + shortRatio, + directionRatio, + useBondPriceScale); } // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- @@ -444,6 +461,48 @@ namespace YLErp.Modules.SwapModule .ToList(); } + /// + /// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个 + /// 可替换入口,测试和历史调用方可以继续注入内存数据。 + /// + protected virtual List FindCorporateActionInfos(DateTime settleDate) + { + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && ((x.ExDividendDate.HasValue && x.ExDividendDate.Value == settleDate.Date) + || (x.EffectiveDate.HasValue && x.EffectiveDate.Value == settleDate.Date))) + .ToList(); + } + + /// 查询交易已有公司行为事件,用于登记日/生效日幂等匹配。 + protected virtual List FindCorporateActionEvents(int swapTradeId) + { + return DbContext.swap_event + .Where(x => x.SwapTradeId == swapTradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && !x.Invalid) + .ToList(); + } + + /// 更新已存在的公司行为事件;默认只标记实体,统一由收盘事务保存。 + protected virtual void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + UpdateDbOption(swapEvent); + } + + /// + /// 查找登记日公司行为。登记日只创建待生效审计事件,不参与当日持仓系数计算; + /// EffectiveDate 到达后才由 FindExDividendInfos 命中并改变 Stock/Fund 基线。 + /// + protected virtual List FindRegistrationExDividendInfos(DateTime settleDate) + { + return FindCorporateActionInfos(settleDate) + .Where(x => x.ValidStatus + && x.ExDividendDate.HasValue + && x.ExDividendDate.Value.Date == settleDate.Date) + .ToList(); + } + /// /// 获取公司行为公式使用的收盘价。 /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 @@ -474,6 +533,17 @@ namespace YLErp.Modules.SwapModule return new DividendService(this).GetDividendTaxRateDecimal(); } + public static bool IsCorporateActionInstrument(string instrumentType) + { + // TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路, + // 这里不能用“非空标的类型”放宽,否则会把期权、期货等未验证品种一并启用。 + return string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase) + || string.Equals(instrumentType, ConsGlobal.InstrumentType.Stock, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTrsCorporateActionInstrument(string instrumentType) + => IsCorporateActionInstrument(instrumentType); + #endregion /// @@ -522,7 +592,19 @@ namespace YLErp.Modules.SwapModule var completedFlowEvents = FindCompletedFlowEvents(tradeIds); // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 - var exDividendInfos = FindExDividendInfos(settleDate); + var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List(); + var exDividendInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date == settleDate.Date) + .ToList(); + var registrationInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.ExDividendDate.HasValue + && x.ExDividendDate.Value.Date == settleDate.Date) + .ToList(); var duplicateDividend = exDividendInfos .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(x => x.Count() > 1); @@ -530,6 +612,16 @@ namespace YLErp.Modules.SwapModule { throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); } + // 公司行为去重 - 拦截 + var duplicateRegistration = registrationInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateRegistration != null) + { + // 登记日现金权益不能依赖数据库返回顺序取 First;同一标的同一登记日 + // 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。 + throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录"); + } var exDividendByCode = exDividendInfos.ToDictionary( x => x.UnderlyingCode, x => x, @@ -573,17 +665,32 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 - // 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存 - // 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为 + // 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存 + // 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为 // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + + // 重置基线 var openingEodPositions = PrepareFundOpeningEodPositions( eodPositions, exDividendByCode, settleDate); + + // 构建公司行为前eod持仓 + var corporateActionBeforePositions = BuildCorporateActionBeforePositions( + eodPositions, + posiList); + + // 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。 + // 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。 + var floatPositionsForCompose = eodPositions.Count == 0 + ? PrepareInitialCorporateActionPositions(posiList, exDividendByCode, settleDate) + : posiList; + + // 处理浮动腿归档 var curEodPosis = DealFloatPositions( - posiList, + floatPositionsForCompose, realPosiList, openingEodPositions, todyEodPositions, @@ -591,14 +698,17 @@ namespace YLErp.Modules.SwapModule td, preSettleDate, flowEvents); - // 公司行为 - 分红 - // Fund 现金分红在 EffectiveDate 当日收盘即完成结算: - // TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额, - // 不把同一笔金额留在 PosiDividendSum 待实现字段中。 - ApplyFundCashDividends( + + // 现金分红不在登记日直接读取 ex_dividend_info 累加。 + // 同步任务会把 GiveCashAmount/10 写入 bond_payment_info,Copy/Update EOD 在 + // EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。 + // 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。 + RecordCorporateActionEvents( + td, curEodPosis, - eodPositions, - exDividendByCode, + corporateActionBeforePositions, + registrationInfos, + exDividendInfos, settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); @@ -665,11 +775,11 @@ namespace YLErp.Modules.SwapModule } /// - /// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。 + /// 对 TRS Stock/Fund 浮动腿应用一条已按 EffectiveDate 筛选的份额/价格公司行为。 /// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过 /// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。 - /// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益, - /// 若同时再写 TdPosiDividend 会重复计入。 + /// 该步骤只改 EOD 持仓的份额/价格基线,不生成现金分红流水;现金模式下现金分红 + /// 不下调期初价格,而是由同步任务写入 bond_payment_info,后续付息链路单独计入。 /// /// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50; /// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50, @@ -698,7 +808,7 @@ namespace YLErp.Modules.SwapModule foreach (var position in positions) { if (position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) || string.IsNullOrWhiteSpace(position.UnderlyingCode) || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo) || !dividendInfo.EffectiveDate.HasValue @@ -739,22 +849,25 @@ namespace YLErp.Modules.SwapModule $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); } + // 计算除权系数 var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, corporateActionClosePrice, - dividendTaxRate); - if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { throw new InvalidOperationException( $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数"); } - // PriceRatio 是“除权前收盘价 / 除权参考价”,所以期初价格要除以它;ShareFactor - // 只来自送股/拆合股。10 送 10 时 1000 份/100 变为 2000 份/50,名义本金仍为 100000; - // 每 10 份派现 10 时数量不变、价格基准降为 99,名义本金变为 99000,后续平一半只能扣 49500。 + // Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”, + // 因此期初价格和持仓数量都使用同一个系数:P' = P / M,Q' = Q * M。 + // 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量; + // 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。 var originalQuantity = position.PosiQuantity; position.PosiQuantity = Math.Round( - originalQuantity * factors.ShareFactor, + originalQuantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); position.TdChangedQty = position.PosiQuantity - originalQuantity; @@ -815,10 +928,297 @@ namespace YLErp.Modules.SwapModule } /// - /// 将 Fund 当日现金分红记入 EOD 已实现分红。 + /// 构造审计事件的调整前快照。优先克隆上一 EOD,保证后续调整不会污染历史实体; + /// 交易首日没有 EOD 时才从初始持仓复制,并把累计分红/已实现字段初始化为 0。 + /// + private static List BuildCorporateActionBeforePositions( + IReadOnlyCollection previousPositions, + IReadOnlyCollection initialPositions) + { + if (previousPositions != null && previousPositions.Count > 0) + { + return previousPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + } + + return (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => new eod_swap_position + { + PositionId = x.PositionId, + UnderlyingCode = x.UnderlyingCode, + UnderlyingInstrumentType = x.UnderlyingInstrumentType, + PosiDirection = x.PosiDirection, + PositionType = x.PositionType, + ContractSize = x.ContractSize, + CountRatio = x.CountRatio, + PosiQuantity = x.PosiQuantity, + PosiGrossPrice = x.PosiGrossPrice, + PosiNetPrice = x.PosiNetPrice, + PosiNetFeePrice = x.PosiNetFeePrice, + PosiNetNoFeePrice = x.PosiNetNoFeePrice, + PosiNotionalValue = x.PosiNotionalValue, + PosiTradingFee = x.PosiTradingFee, + PosiFeePending = x.PosiTradingFeePending, + PosiDividendSum = 0m, + RealizedDividend = 0m, + PosiStatus = x.PosiQuantity == 0m ? 1 : 0 + }) + .ToList(); + } + + /// + /// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线。 + /// 不直接修改初始持仓实体,避免重收盘或后续流程再次读取时重复套用系数。 + /// + private List PrepareInitialCorporateActionPositions( + IReadOnlyCollection initialPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + var positions = (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + if (positions.Count == 0 || exDividendByCode == null || exDividendByCode.Count == 0) + { + return positions; + } + + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var info)) + { + continue; + } + + var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice); + ApplyFundCorporateActionToPosition( + position, + info, + closePrice, + GetDividendTaxRate()); + } + + return positions; + } + + /// + /// 写入公司行为生命周期审计事件。 + /// 登记日:保存调整前快照并标记 Applied=false; + /// 真实除权日:使用上一 EOD 与当前 EOD 补齐调整后快照并标记 Applied=true。 + /// 事件数据只追加/补齐,不删除已生效记录, + /// 便于交易回退后通过 BackId 关联新的回退记录。 + /// + protected virtual void RecordCorporateActionEvents( + trade td, + IReadOnlyCollection currentPositions, + IReadOnlyCollection previousPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentPositions == null) + { + return; + } + + var infos = (registrationInfos ?? Array.Empty()) + .Concat(effectiveInfos ?? Array.Empty()) + .Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode)) + .GroupBy(x => new + { + x.id, + x.UnderlyingCode, + ExDividendDate = x.ExDividendDate?.Date, + EffectiveDate = x.EffectiveDate?.Date + }) + .Select(x => x.First()) + .ToList(); + if (infos.Count == 0) + { + return; + } + + var existingEvents = FindCorporateActionEvents(td.id); + foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType))) + { + var info = infos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, + current.UnderlyingCode, + StringComparison.OrdinalIgnoreCase)); + if (info == null) + { + continue; + } + + // 公司行为事件只使用“公司行为记录主键 + PositionId”作为幂等键。 + var matchingEvents = existingEvents + .Select(x => new { Event = x, Data = DeserializeCorporateActionEventData(x.EventData) }) + .Where(x => x.Data != null + && info.id > 0 + && x.Data.ExDividendInfoId == info.id + && x.Data.PositionId == current.PositionId) + .ToList(); + var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied) + ?? matchingEvents.FirstOrDefault(); + var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId); + // 登记日 false 除权日 true + var isEffective = info.EffectiveDate.HasValue + && info.EffectiveDate.Value.Date <= settleDate.Date + && effectiveInfos != null + && effectiveInfos.Any(x => x.id == info.id); + + // 如果没有匹配到事件或事件未生效,则创建新事件。 + if (eventData == null || (!isEffective && eventData.Data.Applied)) + { + // 创建新事件 + var pending = BuildCorporateActionEventData( + info, + previous ?? current, + isEffective ? current : null, + applied: isEffective); + // 生命周期事件的发生日固定为登记日,EffectiveDate 只表示 Q/P 基线切换日。 + // 这样回退后重收盘仍能按原登记日排序和追溯,不会把同一事件拆成两条历史。 + var eventDate = info.ExDividendDate?.Date + ?? info.EffectiveDate?.Date + ?? settleDate.Date; + var created = AddSwapEvent( + eventDate, + td.id, + (int)SwapEventTypeEnum.公司行为, + JsonConvert.SerializeObject(pending), + 0, + false, + BuildCorporateActionReason(pending)); + if (created == null) + { + created = new swap_event(); + } + // 测试接缝和历史实现可能返回只带 id 的实体;统一补齐字段, + // 确保同一收盘事务内的生效步骤能找到刚创建的事件。 + created.EventType = (int)SwapEventTypeEnum.公司行为; + created.SwapTradeId = td.id; + created.ValueDate = eventDate; + created.EventData = JsonConvert.SerializeObject(pending); + created.EventReason = BuildCorporateActionReason(pending); + existingEvents.Add(created); + continue; + } + + // 如果不是生效日或事件已生效,则跳过。 + if (!isEffective || eventData.Data.Applied) + { + continue; + } + + // 生效日只补齐同一事件的 Before/After 快照,不重新套系数:Before* 来自 + // 调整前 EOD,After* 来自生效日当前 EOD,current 已由开盘基线处理完成。 + eventData.Data.BeforeNotional = previous?.PosiNotionalValue ?? eventData.Data.BeforeNotional; + eventData.Data.BeforePrice = previous?.PosiGrossPrice ?? eventData.Data.BeforePrice; + eventData.Data.BeforeQuantity = previous?.PosiQuantity ?? eventData.Data.BeforeQuantity; + eventData.Data.BeforePendingDividend = previous?.PosiDividendSum ?? eventData.Data.BeforePendingDividend; + eventData.Data.AfterNotional = current.PosiNotionalValue; + eventData.Data.AfterPrice = current.PosiGrossPrice; + eventData.Data.AfterQuantity = current.PosiQuantity; + eventData.Data.AfterPendingDividend = current.PosiDividendSum; + eventData.Data.CashFlowChange = current.RealizedDividend - (previous?.RealizedDividend ?? current.RealizedDividend); + eventData.Data.Applied = true; + eventData.Event.EventData = JsonConvert.SerializeObject(eventData.Data); + eventData.Event.EventReason = BuildCorporateActionReason(eventData.Data); + UpdateCorporateActionEventRecord(eventData.Event); + } + } + + public static CorporateActionEventData BuildCorporateActionEventData( + ex_dividend_info info, + eod_swap_position previous, + eod_swap_position current, + bool applied) + { + return new CorporateActionEventData + { + ExDividendInfoId = info.id, + PositionId = (current ?? previous).PositionId, + UnderlyingCode = (current ?? previous).UnderlyingCode, + ExDividendDate = info.ExDividendDate, + EffectiveDate = info.EffectiveDate, + GiveCashAmount = info.GiveCashAmount, + GiveShareAmount = info.GiveShareAmount, + Split = info.Split, + RationedSharesAmount = info.RationedSharesAmount, + RationedSharesPrice = info.RationedSharesPrice, + BeforeNotional = previous?.PosiNotionalValue ?? 0m, + BeforePrice = previous?.PosiGrossPrice ?? 0m, + BeforeQuantity = previous?.PosiQuantity ?? 0m, + AfterNotional = applied ? current?.PosiNotionalValue ?? 0m : 0m, + AfterPrice = applied ? current?.PosiGrossPrice ?? 0m : 0m, + AfterQuantity = applied ? current?.PosiQuantity ?? 0m : 0m, + BeforePendingDividend = previous?.PosiDividendSum ?? 0m, + AfterPendingDividend = applied ? current?.PosiDividendSum ?? 0m : 0m, + CashFlowChange = applied ? (current?.RealizedDividend ?? 0m) - (previous?.RealizedDividend ?? 0m) : 0m, + Applied = applied, + }; + } + + public static bool ShouldCreateCorporateActionEvent( + IEnumerable events, + ex_dividend_info info, + long positionId) + { + if (info == null) + { + return false; + } + + // 幂等键与收盘事件匹配保持一致,只认 ExDividendInfoId + PositionId。 + // 无法反序列化或缺少 ExDividendInfoId 的存量事件均不参与匹配。 + return !(events ?? Enumerable.Empty()).Any(x => + { + if (!SwapEventService.TryDeserializeCorporateActionEventData(x, out var data)) + { + return false; + } + return info.id > 0 + && data.ExDividendInfoId == info.id + && data.PositionId == positionId; + }); + } + + private static CorporateActionEventData DeserializeCorporateActionEventData(string eventData) + { + if (string.IsNullOrWhiteSpace(eventData)) + { + return null; + } + try + { + return JsonConvert.DeserializeObject(eventData); + } + catch (JsonException) + { + return null; + } + } + + private static string BuildCorporateActionReason(CorporateActionEventData data) + { + return SwapEventService.BuildCorporateActionEventReason(data); + } + + /// + /// 兼容旧测试/扩展调用的直接现金分红辅助方法。 /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 - /// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额, - /// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。 + /// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务 + /// 写入 bond_payment_info,EffectiveDate 收盘通过 CalcBondPayment 进入 EOD, + /// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身 + /// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。 /// protected void ApplyFundCashDividends( IReadOnlyCollection currentEodPositions, @@ -827,7 +1227,6 @@ namespace YLErp.Modules.SwapModule DateTime settleDate) { if (currentEodPositions == null - || previousEodPositions == null || exDividendByCode == null || exDividendByCode.Count == 0) { @@ -835,20 +1234,21 @@ namespace YLErp.Modules.SwapModule } var dividendTaxRate = GetDividendTaxRate(); + var previousList = previousEodPositions ?? Array.Empty(); foreach (var current in currentEodPositions) { if (current == null || current.PosiDirection == 0 - || current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType) || string.IsNullOrWhiteSpace(current.UnderlyingCode) || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) - || !dividendInfo.EffectiveDate.HasValue - || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + || !dividendInfo.ExDividendDate.HasValue + || dividendInfo.ExDividendDate.Value.Date != settleDate.Date) { continue; } - var previous = previousEodPositions.FirstOrDefault( + var previous = previousList.FirstOrDefault( x => x != null && x.PositionId == current.PositionId); var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity; var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection); @@ -859,18 +1259,16 @@ namespace YLErp.Modules.SwapModule * directionRatio : 0m; - // 当日浮动端分红 + // 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格, + // 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。 current.TdPosiDividend = RoundMoney(currentDividend); var previousDividendSum = previous?.PosiDividendSum ?? 0m; - // 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红 + // 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红 + // - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。 current.PosiDividendSum = current.PosiQuantity > 0m - ? RoundMoney(previousDividendSum - current.TdCloseDividend) + ? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend) : 0m; - // 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红 - // current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend); - // 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红 - current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend); - // 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红 + // 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。 current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( current.PosiMtmPnL, current.PosiDividendSum, @@ -883,10 +1281,12 @@ namespace YLErp.Modules.SwapModule } /// - /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 + /// 将一条真实生效日公司行为应用到盘中实时 TRS Stock/Fund 浮动腿。 /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 /// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。 /// 例:8 月 14 日 EOD 为 1000/100,8 月 17 日生效的 10 送 10 会得到 2000/50。 + /// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段, + /// 不改变 Stock/Fund 的期初价格。 /// public static bool ApplyFundCorporateActionToPosition( swap_position position, @@ -897,7 +1297,7 @@ namespace YLErp.Modules.SwapModule if (position == null || dividendInfo == null || position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) || corporateActionClosePrice <= 0) { return false; @@ -906,8 +1306,9 @@ namespace YLErp.Modules.SwapModule var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, corporateActionClosePrice, - dividendTaxRate); - if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { throw new InvalidOperationException( $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); @@ -915,7 +1316,7 @@ namespace YLErp.Modules.SwapModule var originalQuantity = position.PosiQuantity; position.PosiQuantity = Math.Round( - originalQuantity * factors.ShareFactor, + originalQuantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); position.PosiGrossPrice = Math.Round( @@ -1203,9 +1604,10 @@ namespace YLErp.Modules.SwapModule var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0); if (!hasDividend) return; - // ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金; - // 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入 - // PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。 + // 公司行为现金分红与债券付息共用既有待实现/支付链路:公司行为步骤只把金额 + // 累加到 PosiDividendSum,这里仍按交易约定的 DividendPayDate 生成支付流水。 + // 公司行为不会调整 Stock/Fund 的期初价格;因此不能再把现金分红从 PosiMtmPnL + // 中剥离或当作已实现收益提前写入。 var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; if (dividendPayDateOffset <= 0) return; diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index 782b83e1..b9be5178 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text; @@ -206,14 +207,120 @@ namespace YLErp.Modules.SwapModule return events; } /// - /// 获取交易操作历史 + /// 获取交易操作历史。返回前会过滤掉登记日创建且尚未应用(Applied=false) + /// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史。 /// /// 交易id /// public List GetOpreationHistorys(int tradeId) { - List list = DbContext.swap_event.Where(x => x.SwapTradeId == tradeId).OrderByDescending(o => o.id).ToList(); - return list; + List list = DbContext.swap_event + .Where(x => x.SwapTradeId == tradeId) + .OrderByDescending(o => o.id) + .ToList(); + return FilterOperationHistory(list); + } + + /// + /// 过滤登记日创建且 Applied=false 的待生效公司行为事件,避免交易详情在真正 + /// 调整前展示一条“已完成”历史。其他事件仍保留;无法解析的旧格式公司行为也 + /// 保持可见,审计查询不能因为新 JSON 结构而静默丢失历史记录。 + /// + public static List FilterOperationHistory(IEnumerable events) + { + return (events ?? Enumerable.Empty()) + .Where(x => !IsPendingCorporateActionEvent(x)) + .ToList(); + } + + /// + /// 判断指定事件是否为待生效的公司行为事件:EventType 为公司行为(13), + /// 且 EventData 反序列化后 Applied=false(登记日写入、尚未在真实除权日补齐调整后数据)。 + /// 非公司行为类型、无法解析的旧格式或已应用的事件均返回 false,保证历史审计记录不被误删。 + /// + public static bool IsPendingCorporateActionEvent(swap_event swapEvent) + { + if (swapEvent == null || swapEvent.EventType != (int)SwapEventTypeEnum.公司行为) + { + return false; + } + + if (!TryDeserializeCorporateActionEventData(swapEvent, out var data)) + { + // 非快照格式的历史公司行为保持可见,避免误删审计记录。 + return false; + } + + return !data.Applied; + } + + /// + /// 将 swap_event.EventData 安全反序列化为公司行为快照。事件为空、EventData + /// 为空白或 JSON 格式不匹配时返回 false 并将 data 置 null,调用方据此保留旧格式记录。 + /// + public static bool TryDeserializeCorporateActionEventData( + swap_event swapEvent, + out CorporateActionEventData data) + { + data = null; + if (swapEvent == null || string.IsNullOrWhiteSpace(swapEvent.EventData)) + { + return false; + } + + try + { + data = JsonConvert.DeserializeObject(swapEvent.EventData); + return data != null; + } + catch (JsonException) + { + return false; + } + } + + /// + /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、 + /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。 + /// + public static string BuildCorporateActionEventReason(CorporateActionEventData data) + { + if (data == null) + { + return "公司行为快照为空"; + } + + // 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。 + string D(decimal value) => value.ToString(CultureInfo.InvariantCulture); + string Date(DateTime? value) => value.HasValue + ? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : ""; + + return string.Join("; ", new[] + { + $"公司行为[{data.UnderlyingCode}]", + $"ExDividendDate={Date(data.ExDividendDate)}", + $"EffectiveDate={Date(data.EffectiveDate)}", + $"ExDividendInfoId={data.ExDividendInfoId}", + $"PositionId={data.PositionId}", + $"GiveCashAmount={D(data.GiveCashAmount)}", + $"GiveShareAmount={D(data.GiveShareAmount)}", + $"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}", + $"RationedSharesAmount={D(data.RationedSharesAmount)}", + $"RationedSharesPrice={D(data.RationedSharesPrice)}", + "调整前", + $"BeforeNotional={D(data.BeforeNotional)}", + $"BeforePrice={D(data.BeforePrice)}", + $"BeforeQuantity={D(data.BeforeQuantity)}", + $"BeforePendingDividend={D(data.BeforePendingDividend)}", + "调整后", + $"AfterNotional={D(data.AfterNotional)}", + $"AfterPrice={D(data.AfterPrice)}", + $"AfterQuantity={D(data.AfterQuantity)}", + $"AfterPendingDividend={D(data.AfterPendingDividend)}", + $"CashFlowChange={D(data.CashFlowChange)}", + $"Applied={data.Applied}" + }); } public void DeleteEvent(int tradeId) diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 52e14f22..1198ab79 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1547,8 +1547,25 @@ namespace YLErp.Modules.SwapModule { swapEventService.DeleteExtensionTime(swapEvent.id); } + var corporateActionEvents = DbContext.swap_event + .Where(x => !x.Invalid + && x.SwapTradeId == tradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && x.ValueDate >= valueDate) + .OrderByDescending(x => x.id) + .ToList(); InvalidTradeOptionDatasByDate(tradeId, valueDate, backToBegin); - swapEventService.AddSwapEventDate(valueDate, tradeId, (int)SwapEventTypeEnum.回退, string.Empty, 0, false, $"交易回退至{valueDate:yyyy年MM月dd日}"); + var rollbackEvent = swapEventService.AddSwapEventDate( + valueDate, + tradeId, + (int)SwapEventTypeEnum.回退, + string.Empty, + 0, + false, + $"交易回退至{valueDate:yyyy年MM月dd日}"); + // 公司行为原事件保持有效作为不可篡改审计;回退事件通过 BackId 指向本次 + // 回退影响的最新公司行为事件,后续重收盘会追加新的公司行为事件。 + rollbackEvent.BackId = corporateActionEvents.FirstOrDefault()?.id ?? 0; DbContext.SaveChanges(); if (del) { @@ -1762,6 +1779,12 @@ namespace YLErp.Modules.SwapModule var firstConfirm = false; swapEvents.ForEach(x => { + // 公司行为事件是不可篡改审计日志。回退只追加回退事件,不把原始公司 + // 行为事件置无效;否则无法追溯交易曾经经历过的调整。 + if (x.EventType == (int)SwapEventTypeEnum.公司行为) + { + return; + } if (backToBegin && !firstConfirm && x.EventType == (int)SwapEventTypeEnum.确认交易) { firstConfirm = true; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 598b89c5..1390a183 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -748,44 +748,68 @@ namespace YLErp.Modules.TradeModule.DealModule internal readonly struct CorporateActionFactors { - public CorporateActionFactors(decimal priceRatio, decimal shareFactor) + public CorporateActionFactors(decimal priceRatio) { PriceRatio = priceRatio; - ShareFactor = shareFactor; } public decimal PriceRatio { get; } - public decimal ShareFactor { get; } } /// - /// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式; - /// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。 + /// 按 Excel 公式计算公司行为的除权系数。 + /// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数; + /// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格 + /// 和持仓数量,不再维护独立的旧数量系数。 /// - /// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50, - /// PriceRatio=100/50=2,ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为 - /// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。 - /// - /// - /// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99, - /// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。 + /// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。 /// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。 /// /// internal static CorporateActionFactors CalculateCorporateActionFactors( ex_dividend_info info, decimal closePrice, - decimal dividendRate) + decimal dividendRate, + bool adjustCashDividendPrice = true) { - // (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格) - // ------------------------------------------------------- - // (10 + 送股数量 + 配股数量) - var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate) + // 价格调整模式除权参考价 = + // 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价 + // - ----------------------------------------------------- + // (10 + 送股数 + 配股数) * 拆股倍数 + // 场内链路默认继续把现金派息计入除权参考价; + // TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。 + var cashPriceAdjustment = adjustCashDividendPrice + ? info.GiveCashAmount * (1m - dividendRate) + : 0m; + // 拆股倍数 + var splitFactor = GetSplitFactor(info); + // 除权参考价(TRS) : + // 收盘价 * 10 + 配股数 * 配股价 + // ------------------------------ + // (10 + 送股数 + 配股数) * 拆股倍数 + var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment + info.RationedSharesAmount * info.RationedSharesPrice) - / (10m + info.GiveShareAmount + info.RationedSharesAmount); + / (10m + info.GiveShareAmount + info.RationedSharesAmount)) + / splitFactor; + // 除权系数 = 股权登记日收盘价 / 除权除息参考价 var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice; - var shareFactor = 1m + info.GiveShareAmount / 10m; - return new CorporateActionFactors(priceRatio, shareFactor); + return new CorporateActionFactors(priceRatio); + } + + private static decimal GetSplitFactor(ex_dividend_info info) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ArgumentOutOfRangeException(nameof(info.Split), "拆/合股倍数必须大于 0"); + } + + // Split 为空表示未提供拆合股信息,按 1 兼容历史记录;例如 Split=0.1 时, + // 1000 份/100 元调整为 100 份/1000 元。0 或负数无法表达有效份额比例,直接拒绝。 + return info.Split ?? 1m; } /// @@ -798,10 +822,9 @@ namespace YLErp.Modules.TradeModule.DealModule } /** - * 在没有现金分红和配股时: - * 拆合股:10*closePrice / 10+GiveShareAmount - * 调整后数量 = 原数量 × 除权系数 - * 调整后价格 = 原价格 ÷ 除权系数 + * GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1); + * 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split; + * 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。 */ private decimal GetRatioDecimal(ex_dividend_info info) { @@ -833,9 +856,12 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { - // 数量只按送股/拆合股调整,现金分红和配股不增加持仓数量;10 送 10 时 - // 1000 份变为 2000 份,价格系数由 GetRatioDecimal 单独计算,不能在此重复套用。 - var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); + // 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在 + // SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按 + // 送股和独立拆合股调整,现金分红和配股不增加持仓数量。 + var result = (decimal)amount + * (1m + info.GiveShareAmount / 10m) + * GetSplitFactor(info); return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } @@ -900,6 +926,7 @@ namespace YLErp.Modules.TradeModule.DealModule : (DateTime?)null, GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, + Split = decimal.TryParse(getColValueFromTable(dt.Rows[i], "拆/合股倍数"), out var split) ? split : (decimal?)null, RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, OptId = OptUser.UserId, @@ -922,6 +949,10 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行真实除权日不正确"); } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ServiceException($"第{i + 1}行拆/合股倍数必须大于0"); + } if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) { throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日"); @@ -985,6 +1016,11 @@ namespace YLErp.Modules.TradeModule.DealModule { target.RationedSharesPrice = source.RationedSharesPrice; } + if (source.Split.HasValue) + { + // Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。 + target.Split = source.Split.Value; + } if (source.EffectiveDate.HasValue) { // EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。 @@ -1026,6 +1062,11 @@ namespace YLErp.Modules.TradeModule.DealModule errMsg = "股权登记日信息不存在"; return false; } + if (item.Split.HasValue && item.Split.Value <= 0m) + { + errMsg = "拆/合股倍数必须大于0"; + return false; + } // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 @@ -1049,6 +1090,12 @@ namespace YLErp.Modules.TradeModule.DealModule item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6); item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6); item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6); + if (item.Split.HasValue) + { + // 拆合股比例可能为 0.01、0.001 等小数,保留 12 位避免导入时 + // 被 6 位金额精度截断;日期字段则在上方统一归一化为自然日。 + item.Split = OtcFormatHelper.FormatValue(item.Split.Value, 12); + } // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录 // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。 @@ -1177,6 +1224,37 @@ namespace YLErp.Modules.TradeModule.DealModule /// public bool checkDividendInfoExecuteStatus(ex_dividend_info info) { + // TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定 + // 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为 + // 7 月 29 日)才禁止修改,避免修改后无法解释已落库的调整前后快照。 + if (info?.EffectiveDate.HasValue == true) + { + var effectiveDate = info.EffectiveDate.Value.Date; + var trsTradeIds = DbContext.trade + .Where(x => x.ValidState != ConsGlobal.InValid + && x.TradeType == "收益互换" + && x.UnderlyingCode == info.UnderlyingCode + && x.TradeDate <= effectiveDate + && x.ExerciseDate >= effectiveDate) + .Select(x => x.id) + .ToList(); + if (trsTradeIds.Count > 0) + { + // 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改 + // 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前 + // 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。 + var hasAppliedEod = DbContext.eod_swap_position.Any(x => + trsTradeIds.Contains(x.SwapTradeId) + && !x.Invalid + && x.UnderlyingCode == info.UnderlyingCode + && x.ValueDate >= effectiveDate); + if (hasAppliedEod) + { + return true; + } + } + } + var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any(); if (eodStatus) { diff --git a/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx b/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx index b5d20fdd892407d30e291202e5e01cf76589631f..d04a1e2222673db3609f3eb05e9b2afc3dd5afef 100644 GIT binary patch literal 7018 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 F@P829G&leN literal 11261 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 eZRCG3|1 - + 操作时间 操作人 操作内容 - 说明 + 说明(含公司行为前后要素) @@ -31,7 +31,7 @@ {{dateFormat(item.OptTime,'YYYY-MM-DD HH:mm:ss')}} {{item.OptName}} {{item.EventTypeName}} - {{item.EventReason}} + {{item.EventReason}} diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index afee45e7..65c9cbb2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -21,6 +21,7 @@ function saveInfo(dataId, rowId) { EffectiveDate: $("#" + rowId + "_EffectiveDate").val(), GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(), GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(), + Split: $("#" + rowId + "_Split").val(), ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(), RationedSharesAmount: $("#" + rowId + "_RationedSharesAmount").val(), RationedSharesPrice: $("#" + rowId + "_RationedSharesPrice").val() @@ -80,6 +81,7 @@ function gridComplete(obj) { EffectiveDate: null, GiveCashAmount: 0.0, GiveShareAmount: 0, + Split: 1, RationedSharesAmount: 0, RationedSharesPrice: 0, OptName: null, @@ -115,6 +117,8 @@ var colModelGrid = [{ name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { name: 'GiveShareAmount', label: '送股股数(10股)', index: 'GiveShareAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, +}, { + name: 'Split', label: '拆/合股倍数', index: 'Split', width: 100, formatter: { number: { decimalPlaces: 6, defaultValue: '1' } }, editable: true, editrules: { number: true }, }, { name: 'RationedSharesAmount', label: '配股股数(10股)', index: 'RationedSharesAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { From b2f4e167828a361da470379beaa49885281cadd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 20 Aug 2026 13:30:36 +0800 Subject: [PATCH 04/31] =?UTF-8?q?feat(bond):=20=E6=94=AF=E6=8C=81=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E5=92=8C=E5=9F=BA=E9=87=91=E7=8E=B0=E9=87=91=E5=88=86?= =?UTF-8?q?=E7=BA=A2=E7=BA=B3=E5=85=A5=E5=80=BA=E5=88=B8=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=20-=20=E6=94=B6=E7=B4=A7=E8=BF=87=E5=BA=A6?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现股票/基金现金分红数据从 ex_dividend_info 同步到 BondPayment - 新增公司行为去重机制,避免镜像任务完成后重复计息 - 统一现金分红存储口径为"每 10 份派现金额",保持与同步任务一致性 - 修改 CalcPayment 方法,股票/基金分红需除以 10 转换实际现金金额 - 添加单元测试验证债券票息和股票/基金分红的不同计算方式 - 更新文档注释说明"每 10 份派现金额"存储规范 - 修复公司行为生效日处理逻辑,确保正确应用除权系数 - 扩展测试覆盖股票类证券的公司行为处理场景 --- .../BondPaymentServiceCalculationTest.cs | 51 +++ .../CorporateActionEventLifecycleTest.cs | 16 +- ...undCorporateActionRollbackAndUnwindTest.cs | 32 ++ .../SwapModule/SplitCorporateActionTddTest.cs | 16 +- .../SwapPositionComposeScenarioTest.cs | 50 +-- .../SwapModule/TestableSwapDealService.cs | 2 - .../TestableSwapEodPositionService.cs | 23 -- .../Modules/EodModule/BondPaymentService.cs | 59 ++- .../Modules/SwapModule/SwapDealService.cs | 67 ++-- .../SwapModule/SwapEodPositionService.cs | 378 ++++++++---------- .../Modules/SwapModule/SwapEventService.cs | 39 +- .../SwapModule/SwapTradeBaseService.cs | 13 +- .../TradeModule/DealModule/DividendService.cs | 6 + 13 files changed, 384 insertions(+), 368 deletions(-) create mode 100644 UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs diff --git a/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs new file mode 100644 index 00000000..822de107 --- /dev/null +++ b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs @@ -0,0 +1,51 @@ +using YLErp.DBModels; +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class BondPaymentServiceCalculationTest + { + private static BondPaymentService CreateService() + { + return new BondPaymentService( + new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest)); + } + + [TestMethod] + public void CalcPayment_BondCoupon_KeepsPerHundredScale() + { + var payments = new List + { + new BondPayment { payment_interest = 1m } + }; + + // 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m); + + Assert.AreEqual(10m, actual); + } + + [TestMethod] + public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale() + { + var payments = new List + { + new BondPayment { payment_interest = 10m } + }; + + // GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10; + // 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m, + useBondPriceScale: false); + + Assert.AreEqual(1000m, actual); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs index 7e755187..7e202a38 100644 --- a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -113,7 +113,7 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void OperationHistory_FiltersPendingCorporateActionOnly() + public void OperationHistory_PreservesPendingCorporateActionForAudit() { var info = CreateAction(80, ConsGlobal.InstrumentType.Stock); var pendingData = SwapEodPositionService.BuildCorporateActionEventData( @@ -133,12 +133,12 @@ namespace YLErp.Modules.SwapModule new swap_event { id = 3, EventType = (int)SwapEventTypeEnum.互换, EventData = "{}" } }; - var visible = SwapEventService.FilterOperationHistory(events); - - Assert.AreEqual(2, visible.Count); - CollectionAssert.DoesNotContain(visible.Select(x => x.id).ToList(), 1L); - CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 2L); - CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 3L); + // 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。 + Assert.AreEqual(3, events.Count); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot)); + Assert.IsFalse(pendingSnapshot.Applied); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot)); + Assert.IsTrue(appliedSnapshot.Applied); } [TestMethod] @@ -158,7 +158,7 @@ namespace YLErp.Modules.SwapModule var info = CreateAction(81, ConsGlobal.InstrumentType.Stock); info.GiveShareAmount = 10m; - var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition( + var applied = SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs index 8531e661..1f3a6d93 100644 --- a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -77,6 +77,38 @@ namespace YLErp.Modules.SwapModule "未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程"); } + [TestMethod] + public void FCA_UW_008_股票TRS平仓恢复有效Eod基线() + { + var realtime = CreateRealtimeFundPosition(); + realtime.UnderlyingCode = "STOCK.TEST"; + realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + realtime.PosiQuantity = 1000m; + realtime.PosiGrossPrice = 100m; + realtime.PosiNetPrice = 100m; + realtime.PosiNetFeePrice = 100m; + realtime.PosiNetNoFeePrice = 100m; + realtime.PosiNotionalValue = 100000m; + + var eod = CreateEod(ExDate, 2000m, 50m); + eod.UnderlyingCode = "STOCK.TEST"; + eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "Stock TRS 生效日盘中平仓应使用有效 EOD 价格"); + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + [TestMethod] public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() { diff --git a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs index 022ad4df..2cd6ba1f 100644 --- a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs +++ b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs @@ -14,7 +14,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(split: 10m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(1000m, position.PosiQuantity); @@ -27,7 +27,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(split: 0.1m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(10m, position.PosiQuantity); @@ -40,7 +40,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(giveShare: 10m, split: null); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(200m, position.PosiQuantity); @@ -54,7 +54,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(giveShare: 5m, split: 2m); // (1 + 5 / 10) * 2 = 3:100 份/100 元变为 300 份/约 33.333333333 元。 - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(300m, position.PosiQuantity); @@ -67,7 +67,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(cash: 10m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(100m, position.PosiQuantity); @@ -82,7 +82,7 @@ namespace YLErp.Modules.SwapModule rationedSharesAmount: 1m, rationedSharesPrice: 50m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); // Excel L-N:L=(100*10+1*50)/(10+1)=95.4545...,M=100/L; @@ -97,7 +97,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(split: 0m); Assert.ThrowsException(() => - SwapEodPositionService.ApplyFundCorporateActionToPosition( + SwapEodPositionService.ApplyCorporateActionToPosition( CreateFundPosition(), info, 100m, 0m)); } @@ -107,7 +107,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(split: -1m); Assert.ThrowsException(() => - SwapEodPositionService.ApplyFundCorporateActionToPosition( + SwapEodPositionService.ApplyCorporateActionToPosition( CreateFundPosition(), info, 100m, 0m)); } diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 9b8e5677..a750ccec 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -97,19 +97,10 @@ namespace YLErp.Modules.SwapModule public void ExecuteFundCorporateActions( IReadOnlyCollection positions, - IReadOnlyCollection previousEodPositions, - IReadOnlyCollection flowEvents, IReadOnlyCollection dividendInfos) { - ApplyFundCorporateActions( + ApplyCorporateActions( positions, - previousEodPositions, - flowEvents, - dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), - SettleDate); - ApplyFundCashDividends( - positions, - previousEodPositions, dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), SettleDate); } @@ -315,8 +306,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(2000m, actual.PosiQuantity); @@ -381,25 +370,24 @@ namespace YLErp.Modules.SwapModule new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); - var todayEod = previousEod.Clone(); - todayEod.ValueDate = SettleDate; - todayEod.UnderlyingPrice = 100m; + // 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为; + // 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。 + var firstRunEod = previousEod.Clone(); + firstRunEod.ValueDate = SettleDate; + firstRunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos); - service.ExecuteFundCorporateActions( - new[] { todayEod }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); - service.ExecuteFundCorporateActions( - new[] { todayEod }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); + var rerunEod = previousEod.Clone(); + rerunEod.ValueDate = SettleDate; + rerunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos); - Assert.AreEqual(2000m, todayEod.PosiQuantity); - Assert.AreEqual(1000m, todayEod.TdChangedQty); - Assert.AreEqual(50m, todayEod.PosiGrossPrice); - Assert.AreEqual(100000m, todayEod.PosiNotionalValue); + Assert.AreEqual(2000m, firstRunEod.PosiQuantity); + Assert.AreEqual(1000m, firstRunEod.TdChangedQty); + Assert.AreEqual(50m, firstRunEod.PosiGrossPrice); + Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue); + Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity); + Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice); } [TestMethod] @@ -427,8 +415,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(1000m, actual.PosiQuantity); @@ -559,8 +545,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(10m, actual.PosiQuantity, diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 6663355a..450c52d7 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -65,8 +65,6 @@ namespace YLErp.Modules.SwapModule decimal fallbackPrice) => fallbackPrice; - protected override decimal GetFundDividendTaxRate() => 0m; - public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) => TryRestoreEffectiveFundPosition(unwindData, valueDate); diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 1809123e..6c83858e 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -84,15 +84,6 @@ namespace YLErp.Modules.SwapModule return 1.0; // 本币,汇率=1 } - protected override List FindExDividendInfos(DateTime settleDate) - { - return ExDividendInfos - .Where(x => x.ValidStatus - && x.EffectiveDate.HasValue - && x.EffectiveDate.Value.Date == settleDate.Date) - .ToList(); - } - protected override List FindCorporateActionInfos(DateTime settleDate) { return ExDividendInfos @@ -102,15 +93,6 @@ namespace YLErp.Modules.SwapModule .ToList(); } - protected override List FindRegistrationExDividendInfos(DateTime settleDate) - { - return ExDividendInfos - .Where(x => x.ValidStatus - && x.ExDividendDate.HasValue - && x.ExDividendDate.Value.Date == settleDate.Date) - .ToList(); - } - protected override List FindCorporateActionEvents(int swapTradeId) { return CorporateActionEvents @@ -124,11 +106,6 @@ namespace YLErp.Modules.SwapModule decimal fallbackPrice) => fallbackPrice; - protected override decimal GetDividendTaxRate() - { - return 0m; - } - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action)); diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 8661bdee..c4b56206 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Bibliography; using ExcelDataReader.Log; using YLErp.DBModels; @@ -99,6 +99,52 @@ namespace YLErp.Modules.EodModule public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + + // 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。 + // 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储, + // 即直接存 ex_dividend_info.GiveCashAmount 原值,不做 /10; + // 最后的 /10 由 CalcPayment 非债券分支完成。 + // 去重:镜像任务完成后的正式记录带 jsid = -dividend.id;此处先按该负号标记, + // 或用“同一实际付息日 + 同一派现金额”兜底去重,避免镜像完成后重复计息。 + var corporatePayments = (from dividend in DbContext.ex_dividend_info.AsNoTracking() + join underlying in DbContext.underlying_manager.AsNoTracking() + on dividend.UnderlyingCode equals underlying.UnderlyingCode + where dividend.ValidStatus + && dividend.EffectiveDate.HasValue + && dividend.EffectiveDate.Value > startDate + && dividend.EffectiveDate.Value <= endDate + && dividend.GiveCashAmount != 0 + && dividend.UnderlyingCode == underlyingCode + && (underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock + || underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + select dividend).ToList(); + foreach (var dividend in corporatePayments) + { + // 按“每 10 份派现金额”口径,直接存 GiveCashAmount 原值,与同步任务/CalcPayment 保持一致。 + var paymentInterest = dividend.GiveCashAmount; + var hasMirroredPayment = result.Any(payment => + payment.jsid == -dividend.id + || (payment.payment_date.HasValue + && payment.payment_date.Value.Date == dividend.EffectiveDate.Value.Date + && payment.payment_interest == paymentInterest)); + if (hasMirroredPayment) + { + continue; + } + + result.Add(new BondPayment + { + underlyingCode = dividend.UnderlyingCode, + payment_date_pl = dividend.EffectiveDate, + payment_date = dividend.EffectiveDate, + payment_interest = paymentInterest, + paying_price = paymentInterest, + channel_source = ExDividendDataSources.Manual, + jsid = -dividend.id, + create_time = dividend.OptDate, + update_time = dividend.OptDate + }); + } return result; } @@ -142,8 +188,8 @@ namespace YLErp.Modules.EodModule /// /// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息, /// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红 - /// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金, - /// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 + /// 在 bond_payment_info 中按“每 10 份派现金额”存储,payment_interest * qty / 10 才是实际现金, + /// 不能再套债券的 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 /// /// public decimal CalcPayment( @@ -156,11 +202,12 @@ namespace YLErp.Modules.EodModule var interest = payments.Sum(s => s.payment_interest ?? 0); var paymentAmount = interest * qty; // 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。 - // Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10, - // ×qty 就是“每 10 份派现额 × 持仓份额”,必须保留原金额,不能套债券的 /100。 + // Fund/Stock 公司行为:payment_interest 存的是“每 10 份派现金额”(GiveCashAmount 原值), + // interest × qty 得到“每 10 份派现额 × 持仓份数”,需再 ÷10 才是实际现金; + // 既不能套债券的 /100,也不能直接返回 paymentAmount(那样会放大 10 倍)。 var actualAmount = useBondPriceScale ? BondPriceConverter.ToStorage(paymentAmount) - : paymentAmount; + : paymentAmount / 10; return actualAmount * longRatio * payDirection; } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index be443144..4b9daa62 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -285,7 +285,7 @@ namespace YLErp.Modules.SwapModule return query.FirstOrDefault(); } - /// 查询 valueDate 当日已经生效的最近有效 Fund EOD。 + /// 查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。 protected virtual eod_swap_position FindLatestFundEodPosition( int tradeId, long positionId, @@ -296,7 +296,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 查询 valueDate 当天真正生效的 Fund 公司行为。 + /// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。 /// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有 /// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。 /// @@ -311,7 +311,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 + /// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 /// protected virtual decimal GetFundCorporateActionClosePrice( @@ -328,10 +328,6 @@ namespace YLErp.Modules.SwapModule return Convert.ToDecimal(closePrice); } - /// 读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。 - protected virtual decimal GetFundDividendTaxRate() - => new DividendService(this).GetDividendTaxRateDecimal(); - /// /// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换, /// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。 @@ -351,22 +347,22 @@ namespace YLErp.Modules.SwapModule } /// - /// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。 + /// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。 /// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50 /// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。 - /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD - /// 和固定/利息腿均返回 false,沿用原逻辑。 + /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无 + /// EOD 和固定/利息腿均返回 false,沿用原逻辑。 /// protected virtual bool TryRestoreEffectiveFundPosition( swap_position position, DateTime valueDate) { - // 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 + // 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 // 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值, // 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。 if (position == null || position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + || !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType)) { return false; } @@ -405,15 +401,16 @@ namespace YLErp.Modules.SwapModule if (closePrice <= 0) { throw new ServiceException( - $"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); } - var dividendTaxRate = GetFundDividendTaxRate(); - SwapEodPositionService.ApplyFundCorporateActionToPosition( + // TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。 + // var dividendTaxRate = GetFundDividendTaxRate(); + SwapEodPositionService.ApplyCorporateActionToPosition( position, corporateAction, closePrice, - dividendTaxRate); + 0m); } return true; @@ -445,7 +442,7 @@ namespace YLErp.Modules.SwapModule if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty)) { throw new ServiceException( - $"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); + $"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); } var closeQty = fullClose ? effectiveQty : requestedQty; @@ -550,10 +547,10 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); - // Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, + // Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); - // 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); @@ -590,8 +587,8 @@ namespace YLErp.Modules.SwapModule unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); // 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。 - // 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 - unwindData.PosiNotionalValue = restoredFundBaseline + // 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 + unwindData.PosiNotionalValue = restoredCorporateActionBaseline ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); @@ -783,8 +780,8 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); - // 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 @@ -823,7 +820,7 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = restoredFundBaseline + unwindData.PosiNotionalValue = restoredCorporateActionBaseline ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); @@ -1829,13 +1826,13 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); - // 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水), + // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 - if (restoredFundBaseline) + if (restoredCorporateActionBaseline) { - // 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。 + // 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。 // 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500; // 若仍从 trade 旧值 100000 扣减,会错误留下 50500。 td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); @@ -1902,7 +1899,7 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); - // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。 + // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。 // 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。 var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound @@ -2007,7 +2004,7 @@ namespace YLErp.Modules.SwapModule int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); // 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/Fund 的公司行为 - // 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。 + // 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100。 // 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。 var useBondPriceScale = um == null || !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType); @@ -2326,7 +2323,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照, + // 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照, // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 // 不会在这里回写,属于审批链路的残余风险。 ValidateIncomeValueDate(unwindData, td); @@ -2365,7 +2362,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } - // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。 + // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。 // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); @@ -2462,7 +2459,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。 + // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。 // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) { diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index af6b23ad..85b2d474 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -78,7 +78,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 从已经确认的 Fund EOD 快照恢复实时浮动腿的有效基线。 + /// 从已经确认的 Stock/Fund EOD 快照恢复实时浮动腿的有效基线。 /// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算 /// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为 /// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。 @@ -112,7 +112,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 查询 valueDate 之前最近一份有效 Fund EOD 快照,作为盘中操作的日初基线。 + /// 查询 valueDate 之前最近一份有效 Stock/Fund EOD 快照,作为盘中操作的日初基线。 /// 必须严格使用 < valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据, /// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50,D+1 盘中读取 D; /// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。 @@ -446,25 +446,7 @@ namespace YLErp.Modules.SwapModule .ToList(); } - /// - /// 查找结算日有效的公司行为记录;测试可替换为内存数据。 - /// settleDate 必须是收盘作业使用的日期边界(通常为 00:00:00),这里沿用完整 - /// DateTime 相等匹配;历史数据若带时分秒或为空,不会被静默归入当天,需在作业前 - /// 通过数据预检查处理,而不是让收盘在错误基线上继续计算。 - /// - protected virtual List FindExDividendInfos(DateTime settleDate) - { - return DbContext.ex_dividend_info - .Where(x => x.ValidStatus - && x.EffectiveDate.HasValue - && x.EffectiveDate.Value == settleDate.Date) - .ToList(); - } - - /// - /// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个 - /// 可替换入口,测试和历史调用方可以继续注入内存数据。 - /// + /// 查询登记日或真实生效日命中的有效公司行为。 protected virtual List FindCorporateActionInfos(DateTime settleDate) { return DbContext.ex_dividend_info @@ -490,19 +472,6 @@ namespace YLErp.Modules.SwapModule UpdateDbOption(swapEvent); } - /// - /// 查找登记日公司行为。登记日只创建待生效审计事件,不参与当日持仓系数计算; - /// EffectiveDate 到达后才由 FindExDividendInfos 命中并改变 Stock/Fund 基线。 - /// - protected virtual List FindRegistrationExDividendInfos(DateTime settleDate) - { - return FindCorporateActionInfos(settleDate) - .Where(x => x.ValidStatus - && x.ExDividendDate.HasValue - && x.ExDividendDate.Value.Date == settleDate.Date) - .ToList(); - } - /// /// 获取公司行为公式使用的收盘价。 /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 @@ -523,16 +492,6 @@ namespace YLErp.Modules.SwapModule return Convert.ToDecimal(closePrice); } - /// - /// 公司行为现金分红使用的系统税率,小数形式。 - /// 系统配置按百分数存储(例如 13 表示 13%),公司行为公式需要 0.13;送股本身 - /// 不受该税率影响;此处只负责读取并换算,不在异常时擅自默认为 0。 - /// - protected virtual decimal GetDividendTaxRate() - { - return new DividendService(this).GetDividendTaxRateDecimal(); - } - public static bool IsCorporateActionInstrument(string instrumentType) { // TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路, @@ -700,7 +659,7 @@ namespace YLErp.Modules.SwapModule flowEvents); // 现金分红不在登记日直接读取 ex_dividend_info 累加。 - // 同步任务会把 GiveCashAmount/10 写入 bond_payment_info,Copy/Update EOD 在 + // 同步任务会把 GiveCashAmount(每 10 份派现金额)写入 bond_payment_info,Copy/Update EOD 在 // EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。 // 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。 RecordCorporateActionEvents( @@ -710,6 +669,9 @@ namespace YLErp.Modules.SwapModule registrationInfos, exDividendInfos, settleDate); + // 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要 + // 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。 + UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -741,7 +703,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Fund 公司行为。 + /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。 /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 @@ -765,10 +727,8 @@ namespace YLErp.Modules.SwapModule .Select(x => x.Clone()) .ToList(); // 应用公司行为 - ApplyFundCorporateActions( + ApplyCorporateActions( openingPositions, - Array.Empty(), - Array.Empty(), exDividendByCode, settleDate); return openingPositions; @@ -791,10 +751,8 @@ namespace YLErp.Modules.SwapModule /// EffectiveDate 再生成开盘基线。 /// /// - protected void ApplyFundCorporateActions( + protected void ApplyCorporateActions( IEnumerable positions, - IReadOnlyCollection previousEodPositions, - IReadOnlyCollection flowEvents, IReadOnlyDictionary exDividendByCode, DateTime settleDate) { @@ -803,8 +761,9 @@ namespace YLErp.Modules.SwapModule return; } - // 获取系统税率 - var dividendTaxRate = GetDividendTaxRate(); + // TODO: 现金分红税率接入后,仅价格调整模式需要读取税率;TRS 现金模式下不参与除权系数。 + // var dividendTaxRate = GetDividendTaxRate(); + var dividendTaxRate = 0m; foreach (var position in positions) { if (position.PosiDirection <= 0 @@ -824,41 +783,7 @@ namespace YLErp.Modules.SwapModule if (corporateActionClosePrice <= 0) { throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); - } - - // 当天已有 EOD 且该腿没有流水时,Copy 分支不会恢复价格字段。 - // 先还原前一日基线,避免同一结算日重跑时再次除权。 - var hasPositionFlow = flowEvents.Any(x => x.PositionId == position.PositionId); - var previousPosition = previousEodPositions.FirstOrDefault( - x => x.PositionId == position.PositionId); - // 没有流水 且 有前一日持仓时,恢复前一日价格字段 - if (!hasPositionFlow && previousPosition != null) - { - position.PosiQuantity = previousPosition.PosiQuantity; - position.PosiGrossPrice = previousPosition.PosiGrossPrice; - position.PosiNetPrice = previousPosition.PosiNetPrice; - position.PosiNetFeePrice = previousPosition.PosiNetFeePrice; - position.PosiNetNoFeePrice = previousPosition.PosiNetNoFeePrice; - } - - var denominator = 10m + dividendInfo.GiveShareAmount + dividendInfo.RationedSharesAmount; - if (denominator == 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); - } - - // 计算除权系数 - var factors = DividendService.CalculateCorporateActionFactors( - dividendInfo, - corporateActionClosePrice, - dividendTaxRate, - adjustCashDividendPrice: false); - if (factors.PriceRatio <= 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数"); + $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); } // Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”, @@ -866,37 +791,24 @@ namespace YLErp.Modules.SwapModule // 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量; // 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。 var originalQuantity = position.PosiQuantity; - position.PosiQuantity = Math.Round( - originalQuantity * factors.PriceRatio, - 12, - MidpointRounding.AwayFromZero); + // 计算公司行为发生后的 Q/P + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, + dividendInfo, + corporateActionClosePrice, + dividendTaxRate, + GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode)); + position.PosiQuantity = adjusted.Quantity; position.TdChangedQty = position.PosiQuantity - originalQuantity; - var storagePriceRound = GetStorageDeliveryPriceRound( - position.UnderlyingInstrumentType, - position.UnderlyingCode); - position.PosiGrossPrice = Math.Round( - position.PosiGrossPrice / factors.PriceRatio, - storagePriceRound, - MidpointRounding.AwayFromZero); - position.PosiNetPrice = Math.Round( - position.PosiNetPrice / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - if (position.PosiNetFeePrice.HasValue) - { - position.PosiNetFeePrice = Math.Round( - position.PosiNetFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } - if (position.PosiNetNoFeePrice.HasValue) - { - position.PosiNetNoFeePrice = Math.Round( - position.PosiNetNoFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; var shortRatio = DirectionRatio.LongShort(position.PositionType); var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); @@ -998,16 +910,79 @@ namespace YLErp.Modules.SwapModule } var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice); - ApplyFundCorporateActionToPosition( + ApplyCorporateActionToPosition( position, info, closePrice, - GetDividendTaxRate()); + 0m); } return positions; } + /// + /// 同步公司行为后的实时浮动腿。 + /// 登记日只更新下一交易日 BOD 使用的实时 Q/P,不改当日已落库的 EOD; + /// 生效日则把已调整的 EOD 复制到实时腿。每次都先从当日 EOD 恢复,保证重跑幂等。 + /// + private void UpdateRealtimeCorporateActionPositions( + trade td, + IReadOnlyCollection currentEodPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentEodPositions == null || currentEodPositions.Count == 0) + { + return; + } + + // 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的 + // 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。 + var pendingInfos = (registrationInfos ?? Array.Empty()) + .Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date) + .ToList(); + var appliedInfos = effectiveInfos ?? Array.Empty(); + + foreach (var eod in currentEodPositions.Where(x => x != null + && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType) + && !string.IsNullOrWhiteSpace(x.UnderlyingCode))) + { + var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id + && !x.Invalid + && !x.IsInitial + && x.PositionId == eod.PositionId); + if (realtime == null) + { + continue; + } + + var pending = pendingInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (pending != null) + { + // 必须从登记日 EOD 基线生成下一交易日 BOD,而不是在旧实时腿上继续套系数; + // 这样 100000/100 只会变成一次 200000/50,并且不会把初始腿改掉。 + // 先将实时腿恢复为登记日 EOD 的旧基线,再只对实时腿应用一次公司行为。 + // EOD 仍保持除权前快照;因此 7/10 EOD=100000/100,而 7/13 BOD=200000/50。 + var baseline = eod.Clone(); + UpdateSwapPositionWithRealTime(baseline); + var closePrice = GetFundCorporateActionClosePrice(pending, baseline.PosiGrossPrice); + ApplyCorporateActionToPosition(realtime, pending, closePrice, 0m); + continue; + } + + var applied = appliedInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (applied != null) + { + // 生效日 EOD 已经完成 Q/P 调整,实时腿直接同步最终快照,不再二次套系数。 + UpdateSwapPositionWithRealTime(eod.Clone()); + } + } + } + /// /// 写入公司行为生命周期审计事件。 /// 登记日:保存调整前快照并标记 Applied=false; @@ -1212,72 +1187,66 @@ namespace YLErp.Modules.SwapModule return SwapEventService.BuildCorporateActionEventReason(data); } - /// - /// 兼容旧测试/扩展调用的直接现金分红辅助方法。 - /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 - /// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务 - /// 写入 bond_payment_info,EffectiveDate 收盘通过 CalcBondPayment 进入 EOD, - /// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身 - /// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。 - /// - protected void ApplyFundCashDividends( - IReadOnlyCollection currentEodPositions, - IReadOnlyCollection previousEodPositions, - IReadOnlyDictionary exDividendByCode, - DateTime settleDate) + /// 公司行为调整后的持仓 Q/P 结果,供 EOD、实时腿和盘中平仓共用。 + private readonly struct CorporateActionValues { - if (currentEodPositions == null - || exDividendByCode == null - || exDividendByCode.Count == 0) + public CorporateActionValues(decimal quantity, decimal grossPrice, decimal netPrice, decimal? netFeePrice, decimal? netNoFeePrice) { - return; + Quantity = quantity; + GrossPrice = grossPrice; + NetPrice = netPrice; + NetFeePrice = netFeePrice; + NetNoFeePrice = netNoFeePrice; } - var dividendTaxRate = GetDividendTaxRate(); - var previousList = previousEodPositions ?? Array.Empty(); - foreach (var current in currentEodPositions) + public decimal Quantity { get; } + public decimal GrossPrice { get; } + public decimal NetPrice { get; } + public decimal? NetFeePrice { get; } + public decimal? NetNoFeePrice { get; } + } + + /// + /// 统一计算公司行为后的 Q/P。EOD、实时腿和盘中平仓只负责提供基线, + /// 不再各自复制数量、毛价和净价的调整公式。 + /// + private static CorporateActionValues CalculateCorporateActionValues( + decimal quantity, + decimal grossPrice, + decimal netPrice, + decimal? netFeePrice, + decimal? netNoFeePrice, + ex_dividend_info dividendInfo, + decimal closePrice, + decimal dividendTaxRate, + int grossPriceRound) + { + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + closePrice, + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { - if (current == null - || current.PosiDirection == 0 - || !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType) - || string.IsNullOrWhiteSpace(current.UnderlyingCode) - || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) - || !dividendInfo.ExDividendDate.HasValue - || dividendInfo.ExDividendDate.Value.Date != settleDate.Date) - { - continue; - } - - var previous = previousList.FirstOrDefault( - x => x != null && x.PositionId == current.PositionId); - var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity; - var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection); - var currentDividend = entitlementQuantity > 0m - ? entitlementQuantity / 10m - * dividendInfo.GiveCashAmount - * (1m - dividendTaxRate) - * directionRatio - : 0m; - - // 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格, - // 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。 - current.TdPosiDividend = RoundMoney(currentDividend); - var previousDividendSum = previous?.PosiDividendSum ?? 0m; - // 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红 - // - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。 - current.PosiDividendSum = current.PosiQuantity > 0m - ? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend) - : 0m; - // 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。 - current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( - current.PosiMtmPnL, - current.PosiDividendSum, - current.PosiFeePending)); - SetFloatingRealizedPnl(current); - current.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( - current.InterestProfitSum, - current.PosiProfitSum)); + throw new InvalidOperationException( + $"标的【{dividendInfo?.UnderlyingCode}】计算得到无效除权系数"); } + + var adjustedQuantity = Math.Round(quantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); + var adjustedGrossPrice = Math.Round(grossPrice / factors.PriceRatio, grossPriceRound, MidpointRounding.AwayFromZero); + var adjustedNetPrice = Math.Round(netPrice / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + var adjustedNetFeePrice = netFeePrice.HasValue + ? Math.Round(netFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + var adjustedNetNoFeePrice = netNoFeePrice.HasValue + ? Math.Round(netNoFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + return new CorporateActionValues( + adjustedQuantity, + adjustedGrossPrice, + adjustedNetPrice, + adjustedNetFeePrice, + adjustedNetNoFeePrice); } /// @@ -1288,7 +1257,7 @@ namespace YLErp.Modules.SwapModule /// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段, /// 不改变 Stock/Fund 的期初价格。 /// - public static bool ApplyFundCorporateActionToPosition( + public static bool ApplyCorporateActionToPosition( swap_position position, ex_dividend_info dividendInfo, decimal corporateActionClosePrice, @@ -1303,44 +1272,21 @@ namespace YLErp.Modules.SwapModule return false; } - var factors = DividendService.CalculateCorporateActionFactors( + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, dividendInfo, corporateActionClosePrice, dividendTaxRate, - adjustCashDividendPrice: false); - if (factors.PriceRatio <= 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); - } - - var originalQuantity = position.PosiQuantity; - position.PosiQuantity = Math.Round( - originalQuantity * factors.PriceRatio, - 12, - MidpointRounding.AwayFromZero); - position.PosiGrossPrice = Math.Round( - position.PosiGrossPrice / factors.PriceRatio, - ConsGlobal.SwapDeliveryPriceRound, - MidpointRounding.AwayFromZero); - position.PosiNetPrice = Math.Round( - position.PosiNetPrice / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - if (position.PosiNetFeePrice.HasValue) - { - position.PosiNetFeePrice = Math.Round( - position.PosiNetFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } - if (position.PosiNetNoFeePrice.HasValue) - { - position.PosiNetNoFeePrice = Math.Round( - position.PosiNetNoFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } + ConsGlobal.SwapDeliveryPriceRound); + position.PosiQuantity = adjusted.Quantity; + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; position.PosiNotionalValue = Math.Round( position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, ConsGlobal.MoneyRound, diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index b9be5178..711bc06f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -207,8 +207,8 @@ namespace YLErp.Modules.SwapModule return events; } /// - /// 获取交易操作历史。返回前会过滤掉登记日创建且尚未应用(Applied=false) - /// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史。 + /// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留, + /// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯。 /// /// 交易id /// @@ -218,40 +218,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.SwapTradeId == tradeId) .OrderByDescending(o => o.id) .ToList(); - return FilterOperationHistory(list); - } - - /// - /// 过滤登记日创建且 Applied=false 的待生效公司行为事件,避免交易详情在真正 - /// 调整前展示一条“已完成”历史。其他事件仍保留;无法解析的旧格式公司行为也 - /// 保持可见,审计查询不能因为新 JSON 结构而静默丢失历史记录。 - /// - public static List FilterOperationHistory(IEnumerable events) - { - return (events ?? Enumerable.Empty()) - .Where(x => !IsPendingCorporateActionEvent(x)) - .ToList(); - } - - /// - /// 判断指定事件是否为待生效的公司行为事件:EventType 为公司行为(13), - /// 且 EventData 反序列化后 Applied=false(登记日写入、尚未在真实除权日补齐调整后数据)。 - /// 非公司行为类型、无法解析的旧格式或已应用的事件均返回 false,保证历史审计记录不被误删。 - /// - public static bool IsPendingCorporateActionEvent(swap_event swapEvent) - { - if (swapEvent == null || swapEvent.EventType != (int)SwapEventTypeEnum.公司行为) - { - return false; - } - - if (!TryDeserializeCorporateActionEventData(swapEvent, out var data)) - { - // 非快照格式的历史公司行为保持可见,避免误删审计记录。 - return false; - } - - return !data.Applied; + return list; } /// diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index c2d9f3a9..e0068952 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -76,7 +76,12 @@ namespace YLErp.Modules.SwapModule /// public void UpdateSwapPositionWithRealTime(eod_swap_position eodPayPosition) { - var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId); + // 同一 PositionId 同时对应初始腿(id=PositionId)和实时腿(IsInitial=0)。 + // 日终/公司行为同步只能更新实时腿;若直接 FirstOrDefault,会随机命中初始腿, + // 造成 EOD 已是 200000/50 而交易详情仍保持 100000/100,或反向污染交易初始腿。 + var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId + && !x.IsInitial + && !x.Invalid); if (position != null) { position.PosiQuantity = eodPayPosition.PosiQuantity; @@ -96,7 +101,13 @@ namespace YLErp.Modules.SwapModule } else { + // 兼容实时腿尚未生成的首日/自动合成场景:只从初始腿克隆创建实时腿, + // 不能把初始腿当作可更新对象。 position = DbContext.swap_position.FirstOrDefault(x => x.id == eodPayPosition.PositionId); + if (position == null) + { + return; + } var posi = position.Clone(); posi.id = 0; posi.PositionId = position.id; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 1390a183..8b026f5d 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -1252,6 +1252,12 @@ namespace YLErp.Modules.TradeModule.DealModule { return true; } + + // EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。 + // 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的 + // 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断, + // 否则交易已回退仍会被错误判定为“已执行”而无法修改。 + return false; } } From 286a834aa57ec0ebac6f104d8ba88305ede6842c Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 16:09:43 +0800 Subject: [PATCH 05/31] =?UTF-8?q?fix(swap):=20EQD-6977=20=E6=97=A0?= =?UTF-8?q?=E6=97=A5=E7=BB=88=E5=BF=AB=E7=85=A7=E6=97=B6=E6=89=BF=E6=8E=A5?= =?UTF-8?q?=E2=91=A0=E5=85=9C=E5=BA=95=E5=8F=96=E4=BA=8B=E4=BB=B6=E5=9F=BA?= =?UTF-8?q?=E6=95=B0=E2=80=94=E2=80=94=E5=A4=8D=E5=88=A9=E9=87=8D=E6=94=BE?= =?UTF-8?q?=E6=8A=8A=20normalEvent.InterestPrincipal=20=E5=86=99=E4=B8=BA?= =?UTF-8?q?=E6=9C=AB=E6=AC=A1=E5=B9=B6=E6=9C=AC=E9=87=91=E5=90=8E=E5=9F=BA?= =?UTF-8?q?=E6=95=B0(=3D=E8=A2=AB=E5=B9=B3=E4=BB=BD=E9=A2=9D=E6=9C=AC?= =?UTF-8?q?=E9=87=91+=E2=91=A0)=EF=BC=8C=E5=90=8C=E4=B8=BA=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=80=BC=EF=BC=9BUAT=E5=AE=9E=E6=B5=8B(=E6=97=A0?= =?UTF-8?q?=E5=BF=AB=E7=85=A7+=E5=B7=B2=E6=9C=898/19=E9=87=8D=E7=BD=AE)?= =?UTF-8?q?=E5=8E=9F=E2=91=A0=3D0=E5=B0=91=E7=AE=97=E2=89=883.17=E5=85=83?= =?UTF-8?q?=E5=B7=B2=E6=B6=88=E9=99=A4=EF=BC=9B=E9=87=8D=E7=BD=AE=E6=97=A5?= =?UTF-8?q?+=E6=97=A0=E5=BF=AB=E7=85=A7=E9=80=80=E5=8C=96=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E8=A1=A5=E5=91=8A=E8=AD=A6trace=EF=BC=9B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=85=9C=E5=BA=95=E8=B7=AF=E5=BE=84=E6=81=92=E7=AD=89?= =?UTF-8?q?=E5=BC=8F=E6=B5=8B=E8=AF=95(23/23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyBoundaryMatrixTest.cs | 32 ++++++++++++++++--- .../Penalty/PenaltyInterestFeeMerger.cs | 23 ++++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs index b4e05448..6ebb8bf0 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -49,14 +49,14 @@ namespace UnitTestProject.Modules.SwapModule.Penalty ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid" }; - private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread) + private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7) => new() { id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread, InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, - IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" }; @@ -66,16 +66,18 @@ namespace UnitTestProject.Modules.SwapModule.Penalty TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum }; private static decimal RunFee(trade td, swap_position p, decimal settledAmount, - eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread) + eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, + decimal interestPrincipal = 0m, bool maturityCalcLast = true) { var e = new swap_flow_event { PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m, - InterestDirection = 1, InterestClosePnL = settledAmount + InterestDirection = 1, InterestClosePnL = settledAmount, + InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①) }; PenaltyInterestFeeMerger.Merge( td, new List { p }, new List { e }, - unwind, AnnualDays, settled, maturityCalcLast: true, + unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); return e.InterestFee; @@ -170,6 +172,26 @@ namespace UnitTestProject.Modules.SwapModule.Penalty "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)"); } + [TestMethod] + public void 无preEod且此前已有重置_经事件基数兜底_恒等式精确成立() + { + // UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。 + // 兜底① = normalEvent.InterestPrincipal − 本金(复利重放末次并本金后基数); + // 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19] + var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径) + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); + } + [TestMethod] public void 起息日当天平仓_无preEod_恒等式成立() { diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 636481d8..7ca04a77 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -89,8 +89,10 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日(数据契约): - // 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 decimal capitalized = 0m, carryIn = 0m; @@ -98,9 +100,20 @@ public static class PenaltyInterestFeeMerger { var periodDays = position.interest_rest_days ?? 1; var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); - capitalized = unwindOnResetDay - ? (preEod?.InterestIncomeSum ?? 0m) * share - : Math.Max(0m, (preEod?.TdInterestPrincipal ?? 0m) * share - closePrincipal); + if (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + } // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) { From ff7e54008450b5e2abd92d3a5528ad1b6e60cb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 20 Aug 2026 16:10:56 +0800 Subject: [PATCH 06/31] =?UTF-8?q?refactor(swap):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9F=BA=E9=87=91=E5=85=AC=E5=8F=B8=E8=A1=8C=E4=B8=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91=20-=20finished001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 FindFundCorporateActions 方法以支持查询跨日期范围的公司行为记录 - 实现按生效日期和ID顺序稳定排序的多条公司行为记录处理 - 更新平仓时基金基线恢复逻辑以正确处理跨多个生效日的场景 - 在测试类中重写 FindFundCorporateActions 方法以支持单元测试验证 - 添加完整的跨非交易日公司行为恢复功能测试用例 --- ...undCorporateActionRollbackAndUnwindTest.cs | 35 +++++++++++++++++++ .../SwapModule/TestableSwapDealService.cs | 14 ++++++++ .../Modules/EodModule/BondPaymentService.cs | 3 +- .../Modules/SwapModule/SwapDealService.cs | 32 ++++++++++++++--- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs index 1f3a6d93..e8d805f4 100644 --- a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -198,6 +198,41 @@ namespace YLErp.Modules.SwapModule "Fund 份额缩小为 0.01 倍时,直接平仓期初价应为 100 / 0.01 = 10000"); } + [TestMethod] + public void FCA_UW_009_登记日跨非交易日到生效日按范围恢复基金基线() + { + var eodDate = new DateTime(2026, 7, 12); + var effectiveDate = new DateTime(2026, 7, 13); + var unwindDate = new DateTime(2026, 7, 17); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(eodDate, 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + id = 1, + UnderlyingCode = "FUND.TEST", + // 7/10 登记,7/13 生效;7/11、7/12 虽无交易但仍可能存在未除权 EOD 快照。 + ExDividendDate = new DateTime(2026, 7, 10), + EffectiveDate = effectiveDate, + // 生产数据口径:1 拆 2 直接存 Split=2,GiveShareAmount 不参与该拆分。 + GiveShareAmount = 0m, + Split = 2m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = unwindDate; + unwindData.UnwindDate = unwindDate; + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, unwindDate)); + + Assert.AreEqual(2000m, realtime.PosiQuantity, + "7 月 17 日平仓应补应用 7 月 13 日生效的 Split=2,公司行为不能只按平仓日命中"); + Assert.AreEqual(50m, realtime.PosiGrossPrice); + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + [TestMethod] public void FCA_UW_003_正式平仓按FundEod基线重算PnL和现金() { diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 450c52d7..dcfde1d7 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -60,6 +60,20 @@ namespace YLErp.Modules.SwapModule && x.UnderlyingCode == underlyingCode && x.EffectiveDate == valueDate.Date); + protected override List FindFundCorporateActions( + string underlyingCode, + DateTime eodDate, + DateTime valueDate) + => ExDividendInfos + .Where(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date > eodDate.Date + && x.EffectiveDate.Value.Date <= valueDate.Date) + .OrderBy(x => x.EffectiveDate) + .ThenBy(x => x.id) + .ToList(); + protected override decimal GetFundCorporateActionClosePrice( ex_dividend_info dividendInfo, decimal fallbackPrice) diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index c4b56206..d5f5f45f 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -98,7 +98,8 @@ namespace YLErp.Modules.EodModule /// public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { - var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode + && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); // 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。 // 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储, diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 4b9daa62..cef8c391 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -310,6 +310,29 @@ namespace YLErp.Modules.SwapModule && x.EffectiveDate.Value == valueDate.Date); } + /// + /// 查询从最近 EOD 之后到平仓日已经生效的 Stock/Fund 公司行为。 + /// 平仓可能跨越登记日、生效日和多个非交易日,因此不能只按 valueDate 命中一条记录。 + /// 调用方已限定为 Stock/Fund 浮动腿;同日多条记录按生效日、主键稳定排序后逐条应用。 + /// + protected virtual List FindFundCorporateActions( + string underlyingCode, + DateTime eodDate, + DateTime valueDate) + { + var fromDate = eodDate.Date; + var toDate = valueDate.Date; + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date > fromDate + && x.EffectiveDate.Value.Date <= toDate) + .OrderBy(x => x.EffectiveDate) + .ThenBy(x => x.id) + .ToList(); + } + /// /// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 @@ -387,13 +410,12 @@ namespace YLErp.Modules.SwapModule } // 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线, - // 不能再次套系数。只有“最近 EOD < EffectiveDate <= valueDate”时, - // 才在盘中恢复后补一次公司行为。 - var corporateAction = FindFundCorporateAction( + // 不能再次套系数。若平仓跨过多个生效日,则按生效日、id 顺序逐条补齐。 + var corporateActions = FindFundCorporateActions( position.UnderlyingCode, + eodPosition.ValueDate, valueDate); - if (corporateAction?.EffectiveDate > eodPosition.ValueDate.Date - && corporateAction.EffectiveDate.Value.Date <= valueDate.Date) + foreach (var corporateAction in corporateActions ?? new List()) { var closePrice = GetFundCorporateActionClosePrice( corporateAction, From 828dc237ab4bf6edb413be457c23a3d7b760eb89 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 16:21:46 +0800 Subject: [PATCH 07/31] =?UTF-8?q?fix(swap):=20EQD-6977=20trace=E5=88=A9?= =?UTF-8?q?=E7=8E=87=E6=9D=A5=E6=BA=90=E6=A0=87=E7=AD=BE=E7=BA=A0=E5=81=8F?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=9B=BA=E5=AE=9A=E8=85=BF=E4=B8=8D=E5=8F=96?= =?UTF-8?q?=E4=BB=B7=E6=97=B6=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=A0=87=E5=AE=9A?= =?UTF-8?q?=E7=9B=98=E5=8F=96=E4=BB=B7(UAT=20tradeId=3D2450=E5=8F=8D?= =?UTF-8?q?=E9=A6=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwapModule/Penalty/PenaltyInterestFeeMerger.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 7ca04a77..a6b2256b 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -78,9 +78,13 @@ public static class PenaltyInterestFeeMerger frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( position, getSpread(position), preEod?.FloatRate, unwindDate, d => tryGetFixing(d, position.FloatRateUnderlyingCode)); - rateSource = preEod != null - ? $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}" - : "定盘取价(unwindDate-1区间)"; + // 来源标签须反映实际路径:固定腿不取价(利差即冻结利率);浮动腿才有快照/取价之分 + if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) + rateSource = "固定腿利差(不取价)"; + else if (preEod != null) + rateSource = $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}"; + else + rateSource = "定盘取价(unwindDate-1区间)"; } catch (Exception ex) { From f33647c5bec978890c05d6282b0079c5b423480c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 20 Aug 2026 16:38:11 +0800 Subject: [PATCH 08/31] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=90=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 2 +- YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 5772c6ba..232622b4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -408,7 +408,7 @@ namespace YLErp.Modules.SwapModule // EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。 // FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。 - RecalculateNormalizedUnwindAmounts(unwindData); + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); } return true; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 07bbac84..3e1a6350 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -791,18 +791,18 @@ namespace YLErp.Modules.SwapModule position.PosiQuantity, position.ContractSize, shortRatio); - position.PosiMtmPnL = RoundMoney(MtmCalc.UnrealizedPnl( + position.PosiMtmPnL = EodPnlCalculator.RoundMoney(MtmCalc.UnrealizedPnl( position.UnderlyingPrice, position.PosiGrossPrice, position.PosiQuantity, position.ContractSize, shortRatio, directionRatio)); - position.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( + position.PosiProfitSum = EodPnlCalculator.RoundMoney(MtmCalc.ReturnLegProfitSum( position.PosiMtmPnL, position.PosiDividendSum, position.PosiFeePending)); - position.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( + position.SwapPositionValue = EodPnlCalculator.RoundMoney(PositionValueCalc.Calc( position.InterestProfitSum, position.PosiProfitSum)); position.PosiStatus = position.PosiQuantity == 0 ? 1 : 0; From 71683083ef1d01e922a6d92fd763d3b822d0d23f Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 17:29:10 +0800 Subject: [PATCH 09/31] =?UTF-8?q?feat(swap):=20EQD-6953=20=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E6=9C=9F=E6=9C=AB=E7=BB=93=E7=AE=97=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E7=8E=87=E5=AD=97=E6=AE=B5=E8=BD=BD=E4=BD=93+=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E7=A1=AE=E8=AE=A4=E4=B9=A6Excel=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E2=80=94=E2=80=94[NotMapped]ExitYtm=E9=9A=8FUnwindData?= =?UTF-8?q?=E8=BF=9Bswap=5Fevent.EventData(=E9=9B=B6=E8=A1=A8=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8F=98=E6=9B=B4)=EF=BC=9B=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8=E4=BB=8E=E6=B5=AE=E5=8A=A8=E8=85=BF=E5=9B=9E=E8=AF=BB?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=9B=BA=E5=AE=9A4=E4=BD=8D=E4=B8=8D?= =?UTF-8?q?=E5=8E=BB=E9=9B=B6(0.0000)=EF=BC=9B=E6=A8=A1=E6=9D=BFnodma=5F01?= =?UTF-8?q?.xlsx=E6=96=B0=E5=A2=9ES=E5=88=97=EF=BC=9B=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E9=81=B5=E3=80=8A=E4=BA=92=E6=8D=A2=E4=BB=B7=E6=A0=BC=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E3=80=8B=E5=B9=B3=E4=BB=93=3DExit(=E9=9D=9EE?= =?UTF-8?q?nd/Close/Final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/SwapFlowEvent.cs | 13 +++++++++++++ .../YLErp.Core/Models/SwapEndConfirmModel.cs | 6 ++++++ .../settlement_template/nodma_01.xlsx | Bin 10445 -> 6130 bytes .../TradeSettleBillGenerator.cs | 7 +++++++ 4 files changed, 26 insertions(+) diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index b635b672..7b61672f 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -400,5 +400,18 @@ namespace YLErp.DBModels [NotMapped] public decimal? InitYtm { get; set; } + /// + /// 期末标的结算收益率(EQD-6953 平仓)。普通债券类收益互换平仓时由债券计算器按 + /// 期末标的交割全价反算(估值日=平仓日 ValueDate),允许手工覆盖。 + /// 命名遵循《互换价格字段命名规范决策文档》时点维度:平仓/了结用 Exit(勿用 End/Close/Final)。 + /// [NotMapped]:不落 swap_flow_event 表列;仅随 UnwindData 序列化进 swap_event.EventData JSON, + /// 由平仓待复核回显(GetSwapEvent)与结算确认书 Excel(TradeSettleBillGenerator) 消费。 + /// ⚠️ 存储口径为【展示态百分数】(如 6.3721 表示 6.3721%),与同页期末交割全价(展示态)一致, + /// 区别于录入页 trade.InitYtm 的存储态小数(0.063721)——两者载体不同、互不干扰,勿"顺手统一"。 + /// 精度:确认书导出固定 4 位小数不去零(ToString("0.0000"));本字段保留 4 位(四舍五入)。 + /// + [NotMapped] + public decimal? ExitYtm { get; set; } + } } diff --git a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs index b796debe..55ddcf9e 100644 --- a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs +++ b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs @@ -45,5 +45,11 @@ namespace YLErp.Models public string DividendIn { get; set; } public string Quantity { get; set; } + + /// + /// 期末标的结算收益率(EQD-6953)。普通债券类收益互换平仓收益率,展示态百分数, + /// 固定 4 位小数不去零("0.0000");非债券/历史无值时为空串。 + /// + public string ExitYtm { get; set; } } } diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx index 2cf6a553190a988bc29368ba950ad764186abc5b..0415cc61533f2eda60684484d8bb2899bc0992c7 100644 GIT binary patch literal 6130 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 H-#+~h&#u-S diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs index 7523323b..e78f361b 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs @@ -67,6 +67,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator decimal interestRate = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestRate); row.InterestRate = interestRate.ToString("0.00%"); var PosiNotionalValue = flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice; + // EQD-6953 期末标的结算收益率:平仓簿记时随 UnwindData 存进 swap_event.EventData, + // 此处从浮动腿(PositionType>0)回读。存储态=展示态百分数(6.3721),导出固定 4 位不去零。 + decimal? exitYtm = null; if (flowEventGroup.EventId.HasValue) { var swapEvent = Context.GetEvent(flowEventGroup.EventId.Value); @@ -74,8 +77,12 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator { swapEvent.unwindData = JsonHelper.Deserialize(swapEvent.EventData); PosiNotionalValue = swapEvent.unwindData.CloseNotionalValue; + exitYtm = swapEvent.unwindData.FlowEvents? + .FirstOrDefault(f => f.PositionType > 0)? + .ExitYtm; } } + row.ExitYtm = exitYtm?.ToString("0.0000") ?? string.Empty; row.Quantity = flowEventGroup.Quantity.ToString("0.00"); row.PosiNotionalValue = PosiNotionalValue.ToString("0.00"); From 039415d44cd45962735000308b7df1b6ac80f004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 20 Aug 2026 17:31:02 +0800 Subject: [PATCH 10/31] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E5=9F=BA=E9=87=91=E7=B1=BB=E4=BA=92=E6=8D=A2=E5=90=88?= =?UTF-8?q?=E7=BA=A6=E5=B9=B3=E4=BB=93=E6=97=B6=E5=85=AC=E5=8F=B8=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 FindFundCorporateActions 方法,明确查询范围为严格大于 eodDate 的开区间,并添加日志记录 - 重命名 TryRestoreEffectiveFundPosition --- .../SwapModule/TestableSwapDealService.cs | 2 +- .../Modules/SwapModule/SwapDealService.cs | 64 +++++++++++++++---- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index dcfde1d7..3e852ba8 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -80,7 +80,7 @@ namespace YLErp.Modules.SwapModule => fallbackPrice; public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) - => TryRestoreEffectiveFundPosition(unwindData, valueDate); + => TryRestoreAndValidateUnwindData(unwindData, valueDate); protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 232622b4..7bd8621d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -225,6 +225,10 @@ namespace YLErp.Modules.SwapModule /// 查询从最近 EOD 之后到平仓日已经生效的 Stock/Fund 公司行为。 /// 平仓可能跨越登记日、生效日和多个非交易日,因此不能只按 valueDate 命中一条记录。 /// 调用方已限定为 Stock/Fund 浮动腿;同日多条记录按生效日、主键稳定排序后逐条应用。 + /// + /// 查询范围说明: + /// - 严格 > eodDate(开区间):EOD 快照本身已经是除权后结果,不能再次套用 + /// - 范围示例:eodDate=8/14(除权后 2000/50),valueDate=8/20,则查询 (8/14, 8/20] 内的记录 /// protected virtual List FindFundCorporateActions( string underlyingCode, @@ -233,7 +237,7 @@ namespace YLErp.Modules.SwapModule { var fromDate = eodDate.Date; var toDate = valueDate.Date; - return DbContext.ex_dividend_info + var corporateActions = DbContext.ex_dividend_info .Where(x => x.ValidStatus && x.UnderlyingCode == underlyingCode && x.EffectiveDate.HasValue @@ -242,6 +246,9 @@ namespace YLErp.Modules.SwapModule .OrderBy(x => x.EffectiveDate) .ThenBy(x => x.id) .ToList(); + + Logger.Info($"[公司行为查询] 标的={underlyingCode} EOD={eodDate:yyyy-MM-dd} 平仓日={valueDate:yyyy-MM-dd} 查询到{corporateActions.Count}条公司行为"); + return corporateActions; } /// @@ -287,7 +294,7 @@ namespace YLErp.Modules.SwapModule /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无 /// EOD 和固定/利息腿均返回 false,沿用原逻辑。 /// - protected virtual bool TryRestoreEffectiveFundPosition( + protected virtual bool TryRestorePositionFromEod( swap_position position, DateTime valueDate) { @@ -298,6 +305,7 @@ namespace YLErp.Modules.SwapModule || position.PosiDirection <= 0 || !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType)) { + Logger.Info($"[公司行为恢复] 跳过非适用场景 positionId={position?.PositionId} direction={position?.PosiDirection} instrumentType={position?.UnderlyingInstrumentType}"); return false; } @@ -305,16 +313,33 @@ namespace YLErp.Modules.SwapModule position.SwapTradeId, position.PositionId, valueDate); - if (eodPosition == null - || HasCompletedFlowAfterFundEod( - position.SwapTradeId, - position.PositionId, - eodPosition.ValueDate, - valueDate)) + if (eodPosition == null) { + Logger.Info($"[公司行为恢复] 未找到有效EOD tradeId={position.SwapTradeId} positionId={position.PositionId} valueDate={valueDate:yyyy-MM-dd}"); return false; } + // 验证 EOD 数据完整性 + if (eodPosition.PosiQuantity <= 0 || eodPosition.PosiGrossPrice <= 0) + { + Logger.Info($"[公司行为恢复] EOD快照数据异常 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} qty={eodPosition.PosiQuantity} price={eodPosition.PosiGrossPrice}"); + return false; + } + + if (HasCompletedFlowAfterFundEod( + position.SwapTradeId, + position.PositionId, + eodPosition.ValueDate, + valueDate)) + { + Logger.Info($"[公司行为恢复] EOD后已有完成流水,保持实时持仓 tradeId={position.SwapTradeId} positionId={position.PositionId} eodDate={eodPosition.ValueDate:yyyy-MM-dd}"); + return false; + } + + Logger.Info($"[公司行为恢复] 从EOD恢复基线 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} eodQty={eodPosition.PosiQuantity} eodPrice={eodPosition.PosiGrossPrice}"); + if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition)) { return false; @@ -326,15 +351,24 @@ namespace YLErp.Modules.SwapModule position.UnderlyingCode, eodPosition.ValueDate, valueDate); + foreach (var corporateAction in corporateActions ?? new List()) { + Logger.Info($"[公司行为应用] 除权前 id={corporateAction.id} " + + $"登记日={corporateAction.ExDividendDate:yyyy-MM-dd} " + + $"生效日={corporateAction.EffectiveDate:yyyy-MM-dd} " + + $"标的={position.UnderlyingCode} Q={position.PosiQuantity} P={position.PosiGrossPrice}"); + var closePrice = GetFundCorporateActionClosePrice( corporateAction, position.PosiGrossPrice); if (closePrice <= 0) { throw new ServiceException( - $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + $"Stock/Fund 标的【{position.UnderlyingCode}】" + + $"登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】" + + $"生效日【{corporateAction.EffectiveDate:yyyy-MM-dd}】" + + $"缺少有效收盘价(id={corporateAction.id}),无法执行除权"); } // TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。 @@ -344,6 +378,8 @@ namespace YLErp.Modules.SwapModule corporateAction, closePrice, 0m); + + Logger.Info($"[公司行为应用] 除权后 id={corporateAction.id} Q={position.PosiQuantity} P={position.PosiGrossPrice} notional={position.PosiNotionalValue}"); } return true; @@ -353,12 +389,12 @@ namespace YLErp.Modules.SwapModule /// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金, /// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。 /// - protected virtual bool TryRestoreEffectiveFundPosition( + protected virtual bool TryRestoreAndValidateUnwindData( UnwindData unwindData, DateTime valueDate) { var position = FindRealtimeFloatPosition(unwindData); - if (!TryRestoreEffectiveFundPosition(position, valueDate)) + if (!TryRestorePositionFromEod(position, valueDate)) { return false; } @@ -482,7 +518,7 @@ namespace YLErp.Modules.SwapModule var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); // Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); // 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); @@ -636,7 +672,7 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); // 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 @@ -1902,7 +1938,7 @@ namespace YLErp.Modules.SwapModule } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 - var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + var restoredCorporateActionBaseline = TryRestoreAndValidateUnwindData(unwindData, unwindData.ValueDate); // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 if (restoredCorporateActionBaseline) From 45d4bc1d26cc78fa9ba00ff516b16b8038daf73d Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 17:34:52 +0800 Subject: [PATCH 11/31] =?UTF-8?q?feat(web):=20EQD-6953=20=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E9=A1=B5DP=E2=86=94YD=E4=BA=92=E7=AE=97=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=BA=93unwindBondCalc.js+17=E4=B8=AAjest?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E2=80=94=E2=80=94=E5=86=BB=E7=BB=93=E4=B8=89?= =?UTF-8?q?=E6=9D=A1=E6=98=93=E9=94=99=E7=BA=A6=E5=AE=9A:=E5=8F=91?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=99=A8=E5=B1=95=E7=A4=BA=E6=80=81=E7=9B=B4?= =?UTF-8?q?=E4=BC=A0=E4=B8=8D=C3=97100/ExitYtm=E5=9B=9B=E4=BD=8D=E5=9B=9B?= =?UTF-8?q?=E8=88=8D=E4=BA=94=E5=85=A5AwayFromZero(=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=B9=A6=E5=AF=BC=E5=87=BA=E5=9B=BA=E5=AE=9A4=E4=BD=8D?= =?UTF-8?q?=E4=B8=8D=E5=8E=BB=E9=9B=B6)/=E5=A4=B1=E8=B4=A5=E5=8F=AA?= =?UTF-8?q?=E6=B8=85=E5=AF=B9=E6=96=B9=E5=AD=97=E6=AE=B5;=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DroundExitYtm(null)=E4=BC=9A=E8=A2=AB=E5=8F=96=E6=95=B4?= =?UTF-8?q?=E6=88=900=E8=A6=86=E7=9B=96=E6=89=8B=E8=BE=93=E7=9A=84?= =?UTF-8?q?=E9=9A=90=E6=82=A3(=E5=8D=95=E6=B5=8B=E6=8B=A6=E6=88=AA);jest?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E6=B8=85=E5=8D=95=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E6=96=B0=E6=96=87=E4=BB=B6;=E7=BA=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=9B=B6=E6=8E=A5=E7=BA=BF,=E4=B8=8D=E5=BD=B1=E5=93=8D?= =?UTF-8?q?=E7=94=9F=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/fe-tests/jest.config.js | 1 + YLErpWeb/fe-tests/unwindBondCalc.test.js | 136 ++++++++++++++++++ .../Scripts/app/swaptrade/unwindBondCalc.js | 128 +++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 YLErpWeb/fe-tests/unwindBondCalc.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js index d60ebdc6..b06bd582 100644 --- a/YLErpWeb/fe-tests/jest.config.js +++ b/YLErpWeb/fe-tests/jest.config.js @@ -25,6 +25,7 @@ module.exports = { // 覆盖率配置(--coverage 时生效) collectCoverageFrom: [ '../wwwroot/Scripts/app/swaptrade/swapCalc.js', + '../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js', '../wwwroot/Scripts/fast/fastVue.base.js', // 逐步加入更多文件 ], diff --git a/YLErpWeb/fe-tests/unwindBondCalc.test.js b/YLErpWeb/fe-tests/unwindBondCalc.test.js new file mode 100644 index 00000000..792617bd --- /dev/null +++ b/YLErpWeb/fe-tests/unwindBondCalc.test.js @@ -0,0 +1,136 @@ +/** + * unwindBondCalc.test.js — 平仓页 DP↔YD 两字段互算纯函数(EQD-6953) + * ============================================================================ + * 冻结三条与录入页不同、极易被"顺手统一"改坏的约定: + * 1. 发计算器的价格是【展示态直传】,不 ×100(录入页是存储态 ×100); + * 2. ExitYtm 保留 4 位、四舍五入(AwayFromZero)——结算确认书导出固定 4 位不去零, + * 界面精度不得低于导出要求; + * 3. 失败只清【对方】字段(平仓页无净价列,仅 DP/YD 两字段)。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindBondCalc + */ +const UnwindBondCalc = require('../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js'); + +const CALC = { cleanPrice: 98.5, dirtyPrice: 99.5, ytm: 6.3721 }; + +describe('getCalcRequest:展示态直传,不做录入页的 ×100 换算', () => { + test('DP 回车:price 原样 99.5(若误加 ×100 会变成 9950),priceType=DP,估值日=平仓日', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', '2026-08-20'); + expect(req.price).toBe(99.5); + expect(req.priceType).toBe('DP'); + expect(req.targetDate).toBe('2026-08-20'); + expect(req.underlyingCode).toBe('240004.IB'); + expect(req.source).toBe('unwind'); + }); + + test('YD 回车:收益率展示态直传(6.3721 而非 0.063721)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 6.3721, 'YD', '2026-08-20'); + expect(req.price).toBe(6.3721); + expect(req.priceType).toBe('YD'); + }); + + test('估值日为空时 targetDate 为 null(兜底,调用方应先校验)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', null); + expect(req.targetDate).toBeNull(); + }); +}); + +describe('roundExitYtm:4 位小数、四舍五入(远离零)', () => { + test('第 5 位进位', () => { + expect(UnwindBondCalc.roundExitYtm(6.37215)).toBe(6.3722); + }); + test('第 5 位舍去', () => { + expect(UnwindBondCalc.roundExitYtm(6.37214)).toBe(6.3721); + }); + test('负收益率同样远离零(负利率债)', () => { + expect(UnwindBondCalc.roundExitYtm(-0.00005)).toBe(-0.0001); + expect(UnwindBondCalc.roundExitYtm(-1.23445)).toBe(-1.2345); + }); +}); + +describe('applyCalcResult:源字段保持、仅回写对方字段', () => { + test('DP 为源:TradingAmountAvg 保持手输值,ExitYtm 被反算覆盖(4位)', () => { + const state = { TradingAmountAvg: 100.123456, ExitYtm: null }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.37215 }, 'DP'); + expect(state.TradingAmountAvg).toBe(100.123456); // 源:保持 + expect(state.ExitYtm).toBe(6.3722); // 对方:反算+4位取整 + }); + + test('YD 为源:ExitYtm 保持手输值,TradingAmountAvg 被反算覆盖(展示态直写)', () => { + const state = { TradingAmountAvg: null, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: 99.5023 }, 'YD'); + expect(state.ExitYtm).toBe(6.3721); // 源:保持 + expect(state.TradingAmountAvg).toBe(99.5023); // 对方:直写(不 ÷100) + }); + + test('计算器未返回对方值时不覆盖(保留手工输入)', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3 }; + UnwindBondCalc.applyCalcResult(state, { ytm: null }, 'DP'); + expect(state.ExitYtm).toBe(6.3); + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: undefined }, 'YD'); + expect(state.TradingAmountAvg).toBe(100.5); + }); + + test('回写值与现值相同(<1e-9)时不写,避免光标跳动', () => { + const state = { TradingAmountAvg: 100, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.3721 }, 'DP'); + expect(state.ExitYtm).toBe(6.3721); + }); +}); + +describe('applyCalcFailure:保留源字段、只清对方字段、标识全清', () => { + test('DP 为源失败:ExitYtm 清空、全价保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: true, YD: false } }; + UnwindBondCalc.applyCalcFailure(state, 'DP'); + expect(state.TradingAmountAvg).toBe(100.5); + expect(state.ExitYtm).toBeNull(); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('YD 为源失败:TradingAmountAvg 清空、收益率保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'YD' }; + UnwindBondCalc.applyCalcFailure(state, 'YD'); + expect(state.ExitYtm).toBe(6.3); + expect(state.TradingAmountAvg).toBeNull(); + }); +}); + +describe('标识状态机(交互约定2/3,对齐录入页)', () => { + test('成功:源字段标源、对方标 AUTO、REV 清空', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: null }; + UnwindBondCalc.applyCalcSuccess(state, 'DP'); + expect(state.bondDriverType).toBe('DP'); + expect(state.bondAuto).toEqual({ DP: false, YD: true }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('手动编辑未回车:清 源/AUTO、本字段累计 REV、不清对方 REV', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondRev: { DP: true, YD: false } }; + UnwindBondCalc.applyManualEdit(state, 'YD'); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: true, YD: true }); // 累计:DP 的 REV 保留 + }); + + test('bondRev 缺失时自动初始化', () => { + const state = {}; + UnwindBondCalc.applyManualEdit(state, 'DP'); + expect(state.bondRev).toEqual({ DP: true, YD: false }); + }); +}); + +describe('FIELDS 映射与 hasValue 守卫', () => { + test('字段映射', () => { + expect(UnwindBondCalc.FIELDS).toEqual({ DP: 'TradingAmountAvg', YD: 'ExitYtm' }); + }); + test('hasValue:空串/null/undefined/NaN 为 false,0 为 true', () => { + expect(UnwindBondCalc.hasValue('')).toBe(false); + expect(UnwindBondCalc.hasValue(null)).toBe(false); + expect(UnwindBondCalc.hasValue(undefined)).toBe(false); + expect(UnwindBondCalc.hasValue(NaN)).toBe(false); + expect(UnwindBondCalc.hasValue(0)).toBe(true); + expect(UnwindBondCalc.hasValue('6.37')).toBe(true); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js new file mode 100644 index 00000000..dc533376 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js @@ -0,0 +1,128 @@ +/** + * unwindBondCalc.js — 平仓页 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 两字段互算(EQD-6953) + * ============================================================================ + * 与录入页(swapTradeEdit.js + swapCalc.js 债券三字段互算)的关键差异,【勿"顺手统一"】: + * 1. 单位状态:录入页三字段(CP/DP/YD)存【存储态小数】(0.995),发计算器前 ×100、回写 ÷100 + * (bondPriceToCalc/bondCalcPriceToStorage);平仓页 TradingAmountAvg 在 initDeal 已转 + * 【展示态百分数】(99.5),ExitYtm 亦定义为展示态(6.3721)——发计算器不换算、回写不换算。 + * 提交时的存储态转换仍由既有 getStorageDeliveryPrice()/dataFormat() 负责。 + * 2. 字段集:只有 DP(TradingAmountAvg)/YD(ExitYtm) 两个字段(平仓页无净价列), + * 失败只清对方字段(录入页 applyBondCalcFailure 清另外两个,字段名也不同,故不复用)。 + * 3. 估值日:用平仓日 deal.ValueDate(结算语义);录入页用互换起始日 StartDate。 + * 4. 精度:ExitYtm 保留 4 位小数、四舍五入(AwayFromZero)——结算确认书导出要求 + * 固定 4 位不去零(ToString("0.0000")),界面精度不得低于该要求。 + * + * 浏览器:挂 window.UnwindBondCalc(需在 swapCalc.js 之后、unwindSwapTrade.js 之前加载)。 + * Node:module.exports(UMD),供 fe-tests/unwindBondCalc.test.js 使用。 + * 命名遵《互换价格字段命名规范决策文档》:平仓/了结时点 = Exit(非 End/Close/Final)。 + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(require('./swapCalc.js')); + } else { + root.UnwindBondCalc = factory(root.SwapCalc); + } +})(typeof self !== 'undefined' ? self : this, function (SwapCalc) { + 'use strict'; + + // driver(回车的"源"字段) → 平仓页浮动腿(floatPosition)字段名 + var FIELDS = { DP: 'TradingAmountAvg', YD: 'ExitYtm' }; + + function hasValue(v) { + return v !== null && v !== undefined && v !== '' && !isNaN(v); + } + + /** + * 构造 /Bond/CalcBond 请求体。 + * ⚠️ price 必须传【展示态百分数】直传(99.5),不做录入页那种 ×100—— + * 平仓页模型里两个字段本就是展示态。此约定由 fe-tests 冻结,防止后人误加换算。 + * source='unwind':BondController 记进日志,便于区分录入页/平仓页来源定位问题。 + */ + function getCalcRequest(underlyingCode, price, driver, valueDate) { + return { + underlyingCode: underlyingCode, + price: Number(price), + priceType: driver, + targetDate: valueDate || null, + source: 'unwind' + }; + } + + /** + * ExitYtm 统一取整:4 位、四舍五入(远离零),对齐后端 decimal ToString("0.0000")。 + * 非数值(null/undefined/NaN)返回 null——供 write 跳过,绝不能把 null 取整成 0 回写 + * (否则计算器未返回 ytm 时会清掉用户手工输入)。 + */ + function roundExitYtm(value) { + if (value === null || value === undefined || isNaN(value)) return null; + return SwapCalc.roundHalfAwayFromZero(value, 4); + } + + /** + * 计算成功回写(交互约定1+2):源字段保持用户手输值,仅回写对方字段(展示态直写)。 + * - driver='DP'(全价回车)→ 回写 ExitYtm = ytm 取整 4 位;计算器未返回 ytm 则不覆盖。 + * - driver='YD'(收益率回车)→ 回写 TradingAmountAvg = dirtyPrice;未返回则不覆盖。 + * ⚠️ 调用方回写 TradingAmountAvg 后须显式重算平仓盈亏(calcFloatClosePnl)。 + * 纯函数,jest 可直接测。 + */ + function applyCalcResult(state, calc, driver) { + function write(field, value) { + if (value === undefined || value === null) return; // 计算器未返回则不覆盖 + var cur = state[field]; + if (typeof cur === 'number' && Math.abs(cur - value) < 1e-9) return; // 无变化不写 + state[field] = value; + } + if (driver === 'DP') { + write('ExitYtm', roundExitYtm(calc && calc.ytm)); + } else if (driver === 'YD') { + write('TradingAmountAvg', calc && calc.dirtyPrice); + } + return state; + } + + /** + * 计算失败落地(交互约定2):保留源字段值(用户可就地改),清对方字段、清全部标识。 + * 与录入页差异:只清"对方"一个字段(录入页清另外两个)。 + */ + function applyCalcFailure(state, driver) { + var other = driver === 'DP' ? 'YD' : 'DP'; + state[FIELDS[other]] = null; + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** 计算成功后的标识(交互约定2):源字段标"源",对方标"AUTO"。 */ + function applyCalcSuccess(state, driver) { + state.bondDriverType = driver; + state.bondAuto = { DP: driver !== 'DP', YD: driver !== 'YD' }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** + * 编辑未回车(失焦/按键)的标识(交互约定3):清 源/AUTO,仅本字段累计标"REV", + * 不联动对方字段(允许计算有问题时手工覆盖 ExitYtm)。同录入页:累计 REV 不互清。 + */ + function applyManualEdit(state, type) { + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + if (!state.bondRev || typeof state.bondRev !== 'object') { + state.bondRev = { DP: false, YD: false }; + } + if (type) state.bondRev[type] = true; + return state; + } + + return { + FIELDS: FIELDS, + hasValue: hasValue, + getCalcRequest: getCalcRequest, + roundExitYtm: roundExitYtm, + applyCalcResult: applyCalcResult, + applyCalcFailure: applyCalcFailure, + applyCalcSuccess: applyCalcSuccess, + applyManualEdit: applyManualEdit + }; +}); From 4bd528f966e1458c6506290f06ba787ecf7f188e Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 17:38:43 +0800 Subject: [PATCH 12/31] =?UTF-8?q?feat(web):=20EQD-6953=20=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E9=A1=B5=E6=8E=A5=E5=85=A5=E6=9C=9F=E6=9C=AB=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E6=94=B6=E7=9B=8A=E7=8E=87=E4=BA=92=E7=AE=97=E2=80=94?= =?UTF-8?q?=E2=80=94SwapUnwind=E6=B5=AE=E5=8A=A8=E7=AB=AF=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=88=97(=E4=BB=85=E6=99=AE=E9=80=9A=E5=80=BA?= =?UTF-8?q?=E5=88=B8=E7=B1=BB=E6=94=B6=E7=9B=8A=E4=BA=92=E6=8D=A2,?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6/=E4=BA=8B=E4=BB=B6/=E6=BA=90AUTO=20REV?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E4=B8=8E=E5=BD=95=E5=85=A5=E9=A1=B5=E5=90=8C?= =?UTF-8?q?=E6=AC=BEvue-swap-price-input)=EF=BC=9BDP=E5=9B=9E=E8=BD=A6?= =?UTF-8?q?=E2=86=92=E7=AE=97ExitYtm(4=E4=BD=8D)=E3=80=81YD=E5=9B=9E?= =?UTF-8?q?=E8=BD=A6=E2=86=92=E5=8F=8D=E7=AE=97=E4=BA=A4=E5=89=B2=E5=85=A8?= =?UTF-8?q?=E4=BB=B7=E5=B9=B6=E9=87=8D=E7=AE=97=E5=B9=B3=E4=BB=93=E7=9B=88?= =?UTF-8?q?=E4=BA=8F=E3=80=81=E6=89=8B=E5=A1=AB=E6=9C=AA=E5=9B=9E=E8=BD=A6?= =?UTF-8?q?=3DREV=E5=8F=AF=E8=A6=86=E7=9B=96=EF=BC=9B=E4=BC=B0=E5=80=BC?= =?UTF-8?q?=E6=97=A5=3D=E5=B9=B3=E4=BB=93=E6=97=A5,=E6=94=B9=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=B8=85=E9=99=88=E6=97=A7=E5=80=BC=EF=BC=9B=E9=98=B2?= =?UTF-8?q?=E5=BE=A1:applyCalcFailure=E9=9D=9E=E6=B3=95driver=E5=8F=AA?= =?UTF-8?q?=E6=B8=85=E6=A0=87=E8=AF=86=E9=98=B2=E8=AF=AF=E6=B8=85=E4=BA=A4?= =?UTF-8?q?=E5=89=B2=E5=85=A8=E4=BB=B7(=E8=A1=A5=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95)=EF=BC=9BclosePercentInterestRefresh?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=E8=A1=A5UnwindBondCalc=20mock=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E9=A1=B5=E9=9D=A2=E8=84=9A=E6=9C=AC=E5=BA=8F;209/209?= =?UTF-8?q?=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml | 11 +- .../closePercentInterestRefresh.test.js | 7 + YLErpWeb/fe-tests/unwindBondCalc.test.js | 9 ++ .../Scripts/app/swaptrade/unwindBondCalc.js | 8 +- .../Scripts/app/swaptrade/unwindSwapTrade.js | 125 +++++++++++++++++- 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 04dd36f2..c1fdaf8d 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -26,6 +26,7 @@ + }
@@ -197,6 +198,8 @@ 期初标的价格 期末标的交割全价% 期末标的价格 + @* EQD-6953 期末标的结算收益率:仅普通债券类收益互换显示,期末交割全价回车反算,可手填覆盖 *@ + 期末标的结算收益率% 数量 交易费用(平仓) 交易费用(待结算) @@ -223,11 +226,17 @@ {{priceFormat(floatPosition.PosiGrossPrice)}} - + + AUTOREV + @* EQD-6953 期末标的结算收益率:回车以它为源反算交割全价(联动盈亏);手填未回车=REV人工输入 *@ + + + AUTOREV + {{formatQuantity(deal.CloseQty)}} diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js index eeeb4310..2fb4a7eb 100644 --- a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -148,6 +148,13 @@ function loadVueApp(model, mockPost) { return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6)); } }, + // EQD-6953:平仓页现于 unwindSwapTrade.js 之前加载 unwindBondCalc.js(见 SwapUnwind.cshtml), + // dataFormat 提交取整会引用其守卫;此处给同款行为的最小 mock(真实实现由 unwindBondCalc.test.js 覆盖) + UnwindBondCalc: { + hasValue(v) { return v !== null && v !== undefined && v !== '' && !isNaN(v); }, + roundExitYtm(v) { return v; }, + applyManualEdit(s) { return s; } + }, _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/fe-tests/unwindBondCalc.test.js b/YLErpWeb/fe-tests/unwindBondCalc.test.js index 792617bd..2937f9a2 100644 --- a/YLErpWeb/fe-tests/unwindBondCalc.test.js +++ b/YLErpWeb/fe-tests/unwindBondCalc.test.js @@ -95,6 +95,15 @@ describe('applyCalcFailure:保留源字段、只清对方字段、标识全清 expect(state.ExitYtm).toBe(6.3); expect(state.TradingAmountAvg).toBeNull(); }); + + test('driver 非法(null):仅清标识、不动任何数值(setValueDate 清陈旧标识场景的防误清守卫)', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: false, YD: true } }; + UnwindBondCalc.applyCalcFailure(state, null); + expect(state.TradingAmountAvg).toBe(100.5); // 绝不能被清 + expect(state.ExitYtm).toBe(6.3); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + }); }); describe('标识状态机(交互约定2/3,对齐录入页)', () => { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js index dc533376..4c52f009 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js @@ -83,10 +83,14 @@ /** * 计算失败落地(交互约定2):保留源字段值(用户可就地改),清对方字段、清全部标识。 * 与录入页差异:只清"对方"一个字段(录入页清另外两个)。 + * driver 非法(null/未知)时仅清标识、不动任何数值——防调用方误清源字段 + * (如 setValueDate 只想清陈旧标识时误传 null,会把 TradingAmountAvg 清掉)。 */ function applyCalcFailure(state, driver) { - var other = driver === 'DP' ? 'YD' : 'DP'; - state[FIELDS[other]] = null; + if (driver === 'DP' || driver === 'YD') { + var other = driver === 'DP' ? 'YD' : 'DP'; + state[FIELDS[other]] = null; + } state.bondDriverType = null; state.bondAuto = { DP: false, YD: false }; state.bondRev = { DP: false, YD: false }; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 1f70d833..764e9623 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -11,6 +11,9 @@ const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); +// EQD-6953 期末标的结算收益率(ExitYtm):展示态百分数(如 6.3721),trimTailZeros:false 不去零; +// 精度由 getInputFormat('yield') 规则给 4 位——不得低于结算确认书导出的固定 4 位(0.0000)。 +const inputFormatUnwindExitYtm = Object.freeze({ append: '', negative: true, trimTailZeros: false }); const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 }); const swapPosiFeeCalc = { normalizeFeeType(feeType) { @@ -61,6 +64,8 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, + // EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算 + isBondTRS: false, // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, @@ -73,10 +78,15 @@ const vue = new Vue({ }, minStartDate() { return this.deal.StartDate; + }, + // EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算 + isBondUnwindLeg() { + return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType); } }, created() { - this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1; + this.isBondTRS = this.deal.StructureType == '普通债券类收益互换'; + this.multiplier = this.isBondTRS ? 100 : 1; this.initDeal(); this.setValueDate(this.deal.ValueDate); }, @@ -151,6 +161,10 @@ const vue = new Vue({ this.floatPosition.TradingAmountAvg, this.floatPosition.UnderlyingInstrumentType, 'grossPrice'); + // EQD-6953 期末结算收益率:提交前统一 4 位四舍五入(幂等;空/非数保持原样不覆盖) + if (UnwindBondCalc.hasValue(this.floatPosition.ExitYtm)) { + this.floatPosition.ExitYtm = UnwindBondCalc.roundExitYtm(this.floatPosition.ExitYtm); + } this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee); this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending); this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn); @@ -174,6 +188,14 @@ const vue = new Vue({ this.deal.UnwindDate = e; this.floatPosition.UnwindDate = e; } + // EQD-6953 估值日(平仓日)变了,已算出的期末结算收益率随之失效:清值+清标识,待用户重新回车计算 + // (applyManualEdit(null) 只清 源/AUTO/REV 不动任何数值;勿用 applyCalcFailure(null)—— + // 它对 null driver 会误清 TradingAmountAvg) + if (this.isBondUnwindLeg && this.floatPosition.ExitYtm != null) { + this.floatPosition.ExitYtm = null; + UnwindBondCalc.applyManualEdit(this.floatPosition, null); + this.syncUnwindBondFlags(); + } if (!isUseApproval) { this.getInterestList(); //this.refreshUnderlyingPrice(); @@ -299,6 +321,107 @@ const vue = new Vue({ thisObj.calcFloatClosePnl(); }); }, + //================================================================================== + // EQD-6953 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 互算 + // 交互对齐录入页(swapTradeEdit.js):回车=以该字段为源调 /Bond/CalcBond 反算对方; + // 失败=保留源字段、清对方、标识全清;手填未回车=REV 不联动(允许计算有问题时手工覆盖)。 + // 关键差异见 unwindBondCalc.js 头注:平仓页两字段均为展示态(不 ×100/÷100),估值日=平仓日 ValueDate。 + //================================================================================== + getYieldInputFormat() { + return swapPricePrecision.getInputFormat( + this.floatPosition && this.floatPosition.UnderlyingInstrumentType, + 'yield', + inputFormatUnwindExitYtm); + }, + //期末交割全价的 input(失焦/回车都会触发):保持既有盈亏联动;债券腿按交互约定3标 REV。 + onEndDeliveryPriceInput() { + this.changeUnderlyingPrice(); + if (this.isBondUnwindLeg) { + UnwindBondCalc.applyManualEdit(this.floatPosition, 'DP'); + this.syncUnwindBondFlags(); + } + }, + //收益率字段编辑(失焦/回车触发,组件内部回车时 enter 随后修正标识):约定3 标 REV 不联动。 + onEndBondPriceEdit(type) { + if (!this.isBondUnwindLeg) return; + UnwindBondCalc.applyManualEdit(this.floatPosition, type); + this.syncUnwindBondFlags(); + }, + //按键实时标 REV(组件 input 事件只在失焦/回车触发,逐键输入期间靠 keydown 清 源/AUTO)。 + //回车/修饰键/导航键/功能键不改标识(SwapCalc.isBondPriceValueKey 白名单)。 + onEndBondPriceKeydown(type, event) { + if (!this.isBondUnwindLeg) return; + if (!SwapCalc.isBondPriceValueKey(event)) return; + UnwindBondCalc.applyManualEdit(this.floatPosition, type); + this.syncUnwindBondFlags(); + }, + //回车=以该字段为源调计算器反算对方字段。 + onEndBondPriceEnter(type) { // type: 'DP'期末交割全价 / 'YD'期末结算收益率 + if (!this.isBondUnwindLeg) return; + if (!this.floatPosition.UnderlyingCode) { main.message("请先选择债券标的"); return; } + var price = type === 'DP' ? this.floatPosition.TradingAmountAvg : this.floatPosition.ExitYtm; + if (!UnwindBondCalc.hasValue(price)) return; // 空/非数:不触发计算器 + this.calcUnwindBond(type); + }, + //以 driver(回车字段)为源调 /Bond/CalcBond。成败路由见 base/main.js __post:业务错误走 reject, + //失败落地必须在 .fail(录入页曾把失败处理只写 .done 导致静默失效,勿重蹈)。 + calcUnwindBond(driver) { + if (!this.isBondUnwindLeg) return; + var fp = this.floatPosition; + fp.bondDriverType = driver; // 先定源,回写时 applyCalcResult 会跳过该字段 + var price = driver === 'DP' ? fp.TradingAmountAvg : fp.ExitYtm; + if (!UnwindBondCalc.hasValue(price)) { + UnwindBondCalc.applyCalcFailure(fp, driver); + this.syncUnwindBondFlags(); + return; + } + if (!this.deal.ValueDate) { + main.message("请先填写平仓日期(作为债券计算器估值日)"); + UnwindBondCalc.applyCalcFailure(fp, driver); + this.syncUnwindBondFlags(); + return; + } + var req = UnwindBondCalc.getCalcRequest(fp.UnderlyingCode, price, driver, this.deal.ValueDate); + otcDebug.log('[unwindBondCalc] enter driver=' + driver + ' req=' + JSON.stringify(req)); + var self = this; + main.post('/Bond/CalcBond', req, { alertFn: main.message }).done(function (resp) { + if (!resp || !resp.obj) { // 网络错误/响应异常:框架已提示,按约定2清对方字段+标识 + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + return; + } + var err = SwapCalc.getBondCalcErrorMessage(resp.obj); + if (err) { // 业务错误(债券不存在/信息不全/参数非法/值域离谱):toast 去重提示,不回写 + if (SwapCalc.shouldShowBondErr(fp, err)) main.message(err); + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + otcDebug.log('[unwindBondCalc] calc-error driver=' + driver + ' msg=' + err); + return; + } + fp._lastBondErr = null; + UnwindBondCalc.applyCalcResult(fp, resp.obj, driver); + UnwindBondCalc.applyCalcSuccess(fp, driver); + self.syncUnwindBondFlags(); + // YD 为源时 TradingAmountAvg 被反算回填,直接赋值不触发 input 事件,须显式重算盈亏 + if (driver === 'YD') self.calcFloatClosePnl(); + otcDebug.log('[unwindBondCalc] success driver=' + driver + + ' ExitYtm=' + fp.ExitYtm + ' TradingAmountAvg=' + fp.TradingAmountAvg + + ' auto=' + JSON.stringify(fp.bondAuto)); + }).fail(function () { + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + otcDebug.log('[unwindBondCalc] post-fail/reject driver=' + driver); + }); + }, + //确保 bondDriverType/bondAuto/bondRev 的变更触发 Vue 2 响应式更新(同录入页 syncBondFlags: + //这些属性不在后端模型里,$set + 全新对象引用 + $forceUpdate 兜底,缺一视图不刷新)。 + syncUnwindBondFlags() { + var fp = this.floatPosition; + this.$set(fp, 'bondDriverType', fp.bondDriverType === undefined ? null : fp.bondDriverType); + this.$set(fp, 'bondAuto', { DP: !!(fp.bondAuto && fp.bondAuto.DP), YD: !!(fp.bondAuto && fp.bondAuto.YD) }); + this.$set(fp, 'bondRev', { DP: !!(fp.bondRev && fp.bondRev.DP), YD: !!(fp.bondRev && fp.bondRev.YD) }); + this.$forceUpdate(); + }, calcFloatClosePnl() {//计算浮动端平仓盈亏 var thisObj = this; let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; From fb9cf9eadd16eda40315cb0f5dfba20c743ffbc4 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 17:41:41 +0800 Subject: [PATCH 13/31] =?UTF-8?q?feat(web):=20EQD-6953=20BondController?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=99=A8=E8=B0=83=E7=94=A8=E8=A1=A5source?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E6=97=A5=E5=BF=97=E2=80=94=E2=80=94=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E9=A1=B5=E8=AF=B7=E6=B1=82=E5=B8=A6source=3Dunwind(?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E5=8F=82=E6=95=B0=E5=90=91=E5=90=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9,=E5=BD=95=E5=85=A5=E9=A1=B5=E4=B8=8D=E4=BC=A0)?= =?UTF-8?q?=EF=BC=9BBondCalcHepler=E6=9C=AC=E5=B0=B1=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=8F=82=E6=95=B0/=E7=BB=93=E6=9E=9C/?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0,=E8=A1=A5=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=BB=B4=E5=BA=A6=E5=8D=B3=E5=8F=AF=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=AE=9A=E4=BD=8D=E6=98=AF=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=BF=98=E6=98=AF=E5=88=AB=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Controllers/BondController.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/YLErpWeb/Controllers/BondController.cs b/YLErpWeb/Controllers/BondController.cs index 36ef4f85..9a367b76 100644 --- a/YLErpWeb/Controllers/BondController.cs +++ b/YLErpWeb/Controllers/BondController.cs @@ -7,8 +7,12 @@ namespace YLErp.Web.Controllers { [AllowAnonymous] - public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null) + public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null, string source = null) { + // EQD-6953:source 标记调用场景(unwind=平仓页;录入页不传)。BondCalcHepler 已记录 + // 完整请求参数与成败结果,此处仅补场景维度,定位问题时先按 source 区分入口再看参数。 + LogFactory.GetLogger("BondController").Info( + $"CalcBond source={source ?? "(未标记)"} underlyingCode={underlyingCode} price={price} priceType={priceType} targetDate={targetDate ?? "(空→代理默认T+1)"}"); string errorMsg; var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType, out errorMsg, targetDate); if (obj == null) From a5a78eab100a02dde5615ed3cd9dcb4d82be8330 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 18:14:40 +0800 Subject: [PATCH 14/31] =?UTF-8?q?feat(swap):=20EQD-5320=20=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E7=A1=AE=E8=AE=A4=E4=B9=A6=E9=82=AE=E4=BB=B6=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E8=A1=A5=E5=AE=9A=E4=BD=8D=E6=97=A5=E5=BF=97=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=B5=84=E9=87=91=E6=8F=90=E7=A4=BA/=E7=BB=93?= =?UTF-8?q?=E6=B8=85=E7=A1=AE=E8=AE=A4/=E7=94=9F=E6=88=90=E4=B8=89?= =?UTF-8?q?=E6=9D=A1=E9=93=BE=E8=B7=AF=E6=AD=A4=E5=89=8D=E5=87=A0=E4=B9=8E?= =?UTF-8?q?=E6=97=A0=E6=97=A5=E5=BF=97=E4=B8=94=E5=AD=98=E5=9C=A8=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=A4=B1=E8=B4=A5:=E2=91=A0SendConfirmEamil=E6=9F=A5?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E5=90=88=E5=90=8C=E5=8E=9Freturn=E7=A9=BA?= =?UTF-8?q?=E4=B8=B2(=E5=89=8D=E7=AB=AF=E5=BD=93=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E9=80=81)=E6=94=B9Error=E6=97=A5=E5=BF=97+=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=8F=91=E9=80=81=E5=A4=B1=E8=B4=A5=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0;=E2=91=A1CheckConfirmDoc/CheckSettlementDoc=E6=8A=9B?= =?UTF-8?q?=E9=94=99=E5=89=8D=E8=A1=A5=E6=89=93=E5=BA=93=E5=86=85trade=5Fc?= =?UTF-8?q?ontract=5Fr=E8=A1=8C=E7=8A=B6=E6=80=81(=E6=97=A0=E8=A1=8C=3D?= =?UTF-8?q?=E4=BB=8E=E6=9C=AA=E7=94=9F=E6=88=90/IsValid=3Dfalse=3D?= =?UTF-8?q?=E9=87=8D=E7=94=9F=E6=88=90=E4=BD=9C=E5=BA=9F/doc=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1);=E2=91=A2DealNoDmaEmail=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=A4=E5=A4=84=E5=AE=A2=E6=88=B7+?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=89=A9=E5=BC=A0=E6=9F=A5=E8=AF=A2=E8=A1=A5?= =?UTF-8?q?Info=E6=97=A5=E5=BF=97=E5=AF=B9=E9=BD=90=E5=8B=BE=E9=80=89vs?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=A4=84=E7=90=86;=E2=91=A3GetFileName?= =?UTF-8?q?=E7=89=A9=E7=90=86=E6=96=87=E4=BB=B6=E7=BC=BA=E5=A4=B1=E5=8E=9F?= =?UTF-8?q?=E9=9D=99=E9=BB=98null=E6=94=B9Error;=E2=91=A4=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E6=9C=AA=E7=94=9F=E6=88=90=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B9=A6=E6=8A=A5=E9=94=99=E8=A1=A5=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6id/=E5=AE=A2=E6=88=B7/=E5=B9=B3=E4=BB=93=E6=97=A5?= =?UTF-8?q?=E4=BE=BF=E4=BA=8E=E5=85=B3=E8=81=94=E6=89=A9=E5=BC=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97;IYcLogger=E6=97=A0Warn=E7=BB=9F=E4=B8=80=E7=94=A8Erro?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TradeSettleBillGenerator.cs | 4 +- .../SwapModule/SwapEndConfirmService.cs | 10 +++- .../SwapModule/SwapEventEmailService.cs | 46 ++++++++++++++++++- .../SwapSettlementBillGenerateService.cs | 6 +++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs index e78f361b..adbb66b5 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs @@ -48,7 +48,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator var confirmNo = Context.Gettrade_contract_r(tradeId, ContractTypeEnum.Trade); if (string.IsNullOrEmpty(confirmNo)) { - throw new ServiceException($"{trade.TradeNumber}未生成交易确认书"); + // 定位要点:带上事件id便于与 SwapSettlementBillGenerateService 的"生成范围扩张日志"对齐—— + // 报错交易常是扩张拉入的同客户同日平仓,并非用户勾选的那笔。 + throw new ServiceException($"{trade.TradeNumber}未生成交易确认书(平仓事件id={flowEventGroup.id}, tradeId={tradeId}, 客户={client.Name}, 平仓日={flowEventGroup.UnwindDate?.ToString("yyyy-MM-dd")});请先为该笔交易生成交易确认书后重试"); } row.TradeNumber = confirmNo; row.ClientName = client.Name; diff --git a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs index c8786ab1..05c0ec86 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs @@ -175,7 +175,15 @@ namespace YLErp.Modules.SwapModule var tradeContract = DbContext.trade_contract_r.Where(x => x.IsValid && x.TradeId == tradeId && x.Type == ContractTypeEnum.Trade).FirstOrDefault(); if (tradeContract == null) { - return ""; + // 定位要点:此处历史上静默 return "",前端当"已发送"但实际未发任何邮件。 + // 打出 warn 并把失败原因透传给前端,便于区分"没生成过交易确认书"与"被重生成作废(IsValid=false)"。 + var rows = DbContext.trade_contract_r.Where(x => x.TradeId == tradeId).Select(x => new { x.Type, x.IsValid, x.ContractCode }).ToList(); + var detail = rows.Any() + ? string.Join(";", rows.Select(r => $"Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}")) + : "trade_contract_r 无任何行"; + LogFactory.GetLogger("SwapEndConfirm").Error( + $"发送交易确认书邮件中止: tradeId={tradeId} 无有效交易确认书(需 IsValid=true 且 Type={ContractTypeEnum.Trade}); 库内实际行: {detail}"); + return "发送失败:未找到有效交易确认书,请先生成后再发送"; } tradeContract.send_email_result = "发送中"; DbContext.SaveChanges(); diff --git a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs index a6bad2cc..f6a8e720 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs @@ -164,6 +164,12 @@ namespace YLErp.Modules.SwapModule && f.UnwindDate == valueDate && f.PayDirection > 0 && eventTypes.Contains(f.EventType) && f.DataState == (int)SwapFlowDateStateEnum.完成 select f; var flowEventList = flowQuery.ToList(); + // 定位要点:发送范围按【客户+日期】整组扩张——用户勾选 A 事件,同客户同日的 B/C 事件会被一并拉入, + // 后续校验报错常是被拉入的事件缺确认书(用户以为"刚生成了还报错")。此日志把勾选与扩张结果对齐打出来。 + LogFactory.GetLogger("SwapEventEmail").Info( + $"资金提示邮件-发送范围: 用户勾选事件[{string.Join(",", EventEmailEmails.Select(s => s.event_id))}] " + + $"客户[{string.Join(",", clientIds.Distinct())}] 日期[{valueDate:yyyy-MM-dd}] " + + $"扩张后实际处理事件[{string.Join(",", flowEventList.Select(f => $"{f.id}:{f.SwapTradeNo}:" + (f.EventType == (int)SwapFlowEventTypeEnum.开仓 ? "开仓" : f.EventType == (int)SwapFlowEventTypeEnum.平仓 ? "平仓" : "T" + f.EventType)))}]"); var docs = new List(); var openFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); var closeFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList(); @@ -216,6 +222,22 @@ namespace YLErp.Modules.SwapModule } if (tradeNumbers.Any()) { + // 定位要点:报错只带交易编号不够定位。补打每笔缺失交易的 trade_contract_r 实际行状态 + //(无行=从未生成 / IsValid=false=被重生成作废 / document 行缺失=确认书文档被清理),命中哪种一眼可辨。 + var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList(); + var missRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId)).ToList(); + var missCodes = missRows.Select(r => r.ContractCode).Distinct().ToList(); + var docCodes = DbContext.trade_contract_document.Where(d => missCodes.Contains(d.Code)).Select(d => d.Code).Distinct().ToList(); + var detail = string.Join(";", missTradeIds.Select(tid => + { + var tradeNo = flowEvents.First(f => f.SwapTradeId == tid).SwapTradeNo; + var rows = missRows.Where(x => x.TradeId == tid).ToList(); + if (!rows.Any()) return $"{tradeNo}: trade_contract_r 无任何行(从未生成交易确认书)"; + return $"{tradeNo}: " + string.Join(",", rows.Select(r => + $"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode},doc存在={(docCodes.Contains(r.ContractCode) ? "是" : "否")}]")); + })); + LogFactory.GetLogger("SwapEventEmail").Error( + $"交易确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效交易确认书; 库内明细: {detail}"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的交易确认书附件,请检查或生成后再发送邮件"); } return list; @@ -265,6 +287,24 @@ namespace YLErp.Modules.SwapModule } if (tradeNumbers.Any()) { + // 定位要点:结算确认书按 SwapFlowEventId 精确匹配。补打每笔缺失平仓事件的合同行状态—— + // 常见原因是生成时的扩张查询覆盖了本事件但用户实际生成的是另一批,或重生成后 IsValid 被作废。 + var missEventIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.id).Distinct().ToList(); + var missRows = DbContext.trade_contract_r.Where(x => missEventIds.Contains(x.SwapFlowEventId ?? 0)).ToList(); + var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList(); + var tradeTypeRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId) && x.Type == ContractTypeEnum.Clearing).ToList(); + var detail = string.Join(";", flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).GroupBy(g => g.SwapTradeNo).Select(g => + { + var rows = missRows.Where(x => x.SwapFlowEventId == g.First().id).ToList(); + var byTrade = tradeTypeRows.Where(x => x.TradeId == g.First().SwapTradeId).ToList(); + return $"{g.Key}(事件{g.First().id}): " + (rows.Any() + ? string.Join(",", rows.Select(r => $"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}]")) + : $"按事件无行; 按交易的Clearing行=" + (byTrade.Any() + ? string.Join(",", byTrade.Select(r => $"[SwapFlowEventId={r.SwapFlowEventId},IsValid={r.IsValid},Code={r.ContractCode}]")) + : "无")); + })); + LogFactory.GetLogger("SwapEventEmail").Error( + $"结算确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效结算确认书(SwapFlowEventId匹配); 库内明细: {detail}"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的结算确认书附件,请检查或生成后再发送邮件"); } return list; @@ -764,7 +804,11 @@ namespace YLErp.Modules.SwapModule if (System.IO.File.Exists(fName)) { return fName; } else - { return null; } + { + // 定位要点:物理文件缺失历史上静默返回 null(附件列表混入 null,发送结果不可预期),补 warn 便于发现"库里行在、盘上文件丢"。 + LogFactory.GetLogger("SwapEventEmail").Error($"邮件附件物理文件缺失: trade_contract_document.Paths={baseName} 映射后={fName} 不存在"); + return null; + } } } } diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs index 2a66f0a8..26b32983 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs @@ -53,6 +53,12 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule var allFlowEvents = DbContext.swap_flow_event.Where(x => eventIds.Contains(x.EventId)).ToList(); var tradeContracts = DbContext.trade_contract_r.Where(x=> tradeIds.Contains(x.TradeId)&&x.Type== ContractTypeEnum.Clearing&&x.IsValid).ToList(); flowEvents = newFlowEvents.ToList(); + // 定位要点:生成范围按【客户+平仓日】扩张——传入的是用户勾选的 flowEventIds, + // 实际生成会包含同客户同日全部平仓流水(PositionType>0)。若某笔交易未生成过交易确认书, + // TradeSettleBillGenerator 会整批抛"XX未生成交易确认书",此日志用于对齐"勾选了什么 vs 实际生成什么"。 + LogFactory.GetLogger("GenerateSingleV2").Info( + $"生成结算确认书-生成范围: 传入flowEventIds[{string.Join(",", flowEventIds)}] " + + $"扩张后待生成[{string.Join(",", flowEvents.Select(f => $"{f.id}:{f.SwapTradeNo}"))}]"); //---------------------------------- // 交易确认书数据 //---------------------------------- From a44960d31e28eba8971063a70d4247ff4b41f021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 20 Aug 2026 18:22:17 +0800 Subject: [PATCH 15/31] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E5=9F=BA=E9=87=91=E7=B1=BB=E4=BA=92=E6=8D=A2=E5=90=88?= =?UTF-8?q?=E7=BA=A6=E5=B9=B3=E4=BB=93=E6=97=B6=E5=85=AC=E5=8F=B8=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 FindFundCorporateActions 方法,明确查询范围为严格大于 eodDate 的开区间,并添加日志记录 - 重命名 TryRestoreEffectiveFundPosition 为 TryRestorePositionFromEod 和 TryRestoreAndValidateUnwindData - 完善 EOD 数据完整性验证,检查持仓数量和价格的有效性 - 添加详细的公司行为查询和应用过程日志记录 - 优化异常信息,包含更多上下文信息如生效日和记录ID - 更新方法调用引用以使用新的方法名称 --- .../Modules/SwapModule/SwapDealService.cs | 42 +++++++++++++++ YLErpWeb/Controllers/SwapTrade2Controller.cs | 26 +++++++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 53 +++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 7bd8621d..b9145466 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -497,6 +497,48 @@ namespace YLErp.Modules.SwapModule #endregion + /// + /// 根据指定日期刷新浮动腿基线(处理公司行为除权) + /// 用于前端修改平仓日期后重新获取除权后的持仓数量和价格 + /// + /// 交易ID + /// 平仓日期 + /// 返回浮动腿的最新基线数据 + public virtual (decimal PositionQty, decimal PosiNotionalValue, decimal PosiGrossPrice, decimal PosiNetPrice, bool IsRestored) RefreshFloatLegBaseline(int tradeId, DateTime valueDate) + { + var td = DbContext.trade.Find(tradeId); + if (td == null) + { + throw new ServiceException("未找到交易信息"); + } + + var positions = DbContext.swap_position.ActiveByTrade(tradeId); + var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + + if (position == null) + { + // 无浮动腿,返回trade表的原始值 + return ( + Convert.ToDecimal(td.TradeAmount), + Convert.ToDecimal(td.StockEqvNotional), + 0m, + 0m, + false + ); + } + + // 尝试恢复除权后的 EOD 基线 + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, valueDate); + + return ( + position.PosiQuantity, + position.PosiNotionalValue, + position.PosiGrossPrice, + position.PosiNetPrice, + restoredCorporateActionBaseline + ); + } + /// /// 平仓初始化 /// diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 33c82881..421b5cc3 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -393,6 +393,32 @@ namespace YLErp.Web.Controllers new SwapTradeService(CurUser).TradeBack(model.TradeId, model.TradeDate); return JsonSuccess("回退成功"); } + /// + /// 根据平仓日期刷新持仓基线(处理公司行为除权) + /// + /// 交易ID + /// 事件日期/平仓日期 + /// 返回最新的持仓数量、价格等基线数据 + public JsonResult RefreshUnwindBaseline(int tradeId, DateTime valueDate) + { + var (positionQty, posiNotionalValue, posiGrossPrice, posiNetPrice, isRestored) = + new SwapDealService(CurUser).RefreshFloatLegBaseline(tradeId, valueDate); + + var result = new + { + PositionQty = positionQty, + PosiNotionalValue = posiNotionalValue, + PosiGrossPrice = posiGrossPrice, + PosiNetPrice = posiNetPrice, + IsRestored = isRestored, + // 全平时,平仓数量等于持仓数量 + CloseQty = positionQty, + CloseNotionalValue = posiNotionalValue + }; + + return JsonSuccess("", result); + } + /// /// 平仓利息腿信息 /// diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 1f70d833..ab2e3c05 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -175,6 +175,8 @@ const vue = new Vue({ this.floatPosition.UnwindDate = e; } if (!isUseApproval) { + // 新增:刷新持仓基线(处理公司行为除权) + this.refreshUnwindBaseline(); this.getInterestList(); //this.refreshUnderlyingPrice(); } else { @@ -365,6 +367,57 @@ const vue = new Vue({ thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); }, + refreshUnwindBaseline() {//刷新持仓基线(处理公司行为除权) + var thisObj = this; + var postData = { + tradeId: thisObj.deal.SwapTradeId, + valueDate: thisObj.deal.ValueDate + }; + main.post("/swaptrade2/RefreshUnwindBaseline", postData, { async: true }).done(function (resp) { + if (resp.success) { + var data = resp.obj; + // 更新持仓数量和名义本金 + thisObj.deal.PositionQty = data.PositionQty; + thisObj.deal.PositionQty2 = data.PositionQty; // 新增:同时更新 PositionQty2(界面显示用) + thisObj.deal.PosiNotionalValue = data.PosiNotionalValue; + + // 如果是全平,更新平仓数量和名义本金 + if (thisObj.deal.CloseMethod == 1) { // 全部平仓 + thisObj.deal.CloseQty = data.CloseQty; + thisObj.deal.CloseNotionalValue = data.CloseNotionalValue; + } + + // 更新浮动腿的期初价格和数量 + if (thisObj.floatPosition) { + thisObj.floatPosition.PosiGrossPrice = data.PosiGrossPrice; + thisObj.floatPosition.PosiNetPrice = data.PosiNetPrice; + thisObj.initPosiNetPrice = data.PosiGrossPrice; + // 更新浮动腿的持仓数量(底部表格显示用) + thisObj.floatPosition.PositionQty = data.PositionQty; + } + + // 重新计算最多可平比例 + thisObj.oriClosePercent = (thisObj.deal.NotionalValue && thisObj.deal.PosiNotionalValue) + ? thisObj.deal.PosiNotionalValue / thisObj.deal.NotionalValue : 1; + + // 如果是全平,更新平仓比例 + if (thisObj.deal.CloseMethod == 1) { + thisObj.deal.ClosePercent = thisObj.oriClosePercent; + } + + // 强制 Vue 更新界面 + thisObj.$forceUpdate(); + + // 可选:显示提示信息 + if (data.IsRestored && window.otcDebug) { + console.log('[公司行为] 已根据除权后的EOD基线刷新持仓数据', { + PositionQty: data.PositionQty, + PosiGrossPrice: data.PosiGrossPrice + }); + } + } + }); + }, getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; // closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算 From 3728d45cc40d8863b93068239d46312a299dca85 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:09:51 +0800 Subject: [PATCH 16/31] =?UTF-8?q?test(web):=20swapPricePrecisionHelper?= =?UTF-8?q?=E8=A1=A519=E4=BE=8B=E7=89=B9=E5=BE=81=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E5=8D=81=E8=BF=9B=E5=88=B6=E6=A0=B8=E5=BF=83=E2=80=94=E2=80=94?= =?UTF-8?q?shiftDecimal/roundDecimal/multiplyDecimal/getRule/roundForSubmi?= =?UTF-8?q?t/format=E5=85=A8golden=E5=80=BC=E6=96=AD=E8=A8=80(0.1=C3=970.2?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE0.02=E3=80=811.005=E2=86=921.01=E7=BB=95?= =?UTF-8?q?=E5=BC=80=E6=B5=AE=E7=82=B9=E9=99=B7=E9=98=B1=E3=80=81roundDeci?= =?UTF-8?q?mal=E7=BB=9D=E5=AF=B9=E5=80=BC=E5=9B=9B=E8=88=8D=E4=BA=94?= =?UTF-8?q?=E5=85=A5=E4=B8=8EExitYtm=E7=BA=A6=E5=AE=9A=E5=90=8C=E5=90=91?= =?UTF-8?q?=E3=80=81=E4=B9=98=E6=B3=95=E4=BF=9D=E7=95=99=E5=B0=8F=E6=95=B0?= =?UTF-8?q?=E6=A0=87=E5=BA=A61.5=C3=972=3D3.0=E3=80=81Bond=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8B=BC=E9=94=99=E2=86=92null=E5=9B=9E=E8=90=BDumpri?= =?UTF-8?q?ce=E8=80=8CStock=E5=9B=9E=E8=90=BD=E5=93=81=E7=A7=8D=E7=BA=A7?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E5=AF=B9=E7=A7=B0=E5=86=BB=E7=BB=93)?= =?UTF-8?q?=EF=BC=9Brequire=E7=9B=B4=E8=BF=9E=E5=8A=A0=E8=BD=BD=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3readFileSync=E6=B2=99=E7=AE=B1(=E5=8F=AF=E8=A2=AB?= =?UTF-8?q?=E6=8F=92=E6=A1=A9)=EF=BC=9B=E8=A6=86=E7=9B=96=E7=8E=87?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E7=BA=B3=E5=85=A5=E8=AF=A5=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=9B=E5=AE=9E=E9=94=A4=E6=B3=A8=E9=87=8A:=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E5=9C=A8rootDir=E5=A4=96=E8=87=B4babel(=E6=97=A0trans?= =?UTF-8?q?former)=E4=B8=8Ev8(=E9=80=83=E9=80=B8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=BD=92=E5=9B=A0,=E5=90=ABroots/=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E5=8F=98=E4=BD=93=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?jest29.7)=E4=B8=A4=E6=8F=90=E4=BE=9B=E5=99=A8=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8E=87=E6=81=920=E4=B8=94=E9=97=A8=E6=A7=9B=E4=B8=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91,=E7=9C=9F=E4=BF=AE=E9=9C=80=E9=87=8D?= =?UTF-8?q?=E6=9E=84rootDir=E5=8F=A6=E8=A1=8C=E7=AB=8B=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/fe-tests/jest.config.js | 4 + .../fe-tests/swapPricePrecisionHelper.test.js | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js index b06bd582..f6877998 100644 --- a/YLErpWeb/fe-tests/jest.config.js +++ b/YLErpWeb/fe-tests/jest.config.js @@ -23,9 +23,13 @@ module.exports = { moduleDirectories: ['node_modules', '../wwwroot/Scripts'], // 覆盖率配置(--coverage 时生效) + // 已知问题:源码在 rootDir(fe-tests) 之外,babel 提供器因 transform:{} 无插桩器、 + // v8 提供器对 rootDir 逃逸路径无法归因(jest 29.7 实测,含 roots/绝对路径变体), + // 覆盖率表恒为 0 且门槛不触发。清单仍保留以固化意图,真实修复需重构 rootDir。 collectCoverageFrom: [ '../wwwroot/Scripts/app/swaptrade/swapCalc.js', '../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js', + '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js', '../wwwroot/Scripts/fast/fastVue.base.js', // 逐步加入更多文件 ], diff --git a/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js new file mode 100644 index 00000000..d30ae970 --- /dev/null +++ b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js @@ -0,0 +1,178 @@ +/** + * swapPricePrecisionHelper.test.js — 字符串精确十进制核心的特征测试 + * ============================================================================ + * 目的:swapPricePrecisionHelper.js 的组件事件(swapPriceInput.component.test.js)与 + * 配置接线(swapPrecisionConfig.test.js)已有测试,但【字符串精确十进制核心】 + * (shiftDecimal / roundDecimal / multiplyDecimal / getRule / format / + * roundForSubmit) 一直只有间接覆盖。精度即资损,这里用 golden 值冻结现状: + * 1. 全部走字符串数位运算,绕开 IEEE754 浮点陷阱(0.1*0.2、1.005.toFixed); + * 2. roundDecimal 是【绝对值上四舍五入】= AwayFromZero,与 unwindBondCalc + * 的 ExitYtm 约定同向; + * 3. roundDecimal 不补零(1.2 → '1.2'),补零是 formatCommon/formatFixed 的职责; + * 4. roundDecimal 对非法输入/负精度【原样返回不归一】,调用方别拿它当校验器; + * 5. getRule 的覆盖优先级:先查 defaults 有无该品种(没有直接 null),再看 + * main.swapPricePrecision 覆盖,覆盖非法则回落 defaults。 + * + * 做法:直接 require 源文件(jest v8 覆盖率据此插桩,readFileSync+new Function + * 的沙箱加载会让覆盖率统计看不见);配置覆盖用 global.main.swapPricePrecision + * 注入——模块内 global 绑定到 globalThis,与本测试文件的 global 同源, + * beforeEach/afterEach 清理防串扰。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest swapPricePrecisionHelper + */ +const helper = require('../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js'); + +function withConfig(config, extras) { + if (config !== undefined) global.main = { swapPricePrecision: config }; + if (extras && extras.otcformat) global.otcformat = extras.otcformat; +} + +beforeEach(() => { delete global.main; delete global.otcformat; }); +afterEach(() => { delete global.main; delete global.otcformat; }); + +describe('shiftDecimal:纯数位平移,不经浮点', () => { + + test('右移补零 / 左移进小数', () => { + expect(helper.shiftDecimal('1.5', 1)).toBe('15'); + expect(helper.shiftDecimal('1.5', -1)).toBe('0.15'); + expect(helper.shiftDecimal('12.34', -3)).toBe('0.01234'); + expect(helper.shiftDecimal('0.001', -2)).toBe('0.00001'); + expect(helper.shiftDecimal('5', 2)).toBe('500'); + }); + + test('保留符号;places=0 或非整数只做归一化', () => { + expect(helper.shiftDecimal('-1.5', 1)).toBe('-15'); + expect(helper.shiftDecimal('1.50', 0)).toBe('1.50'); // 不动尾巴零 + expect(helper.shiftDecimal('1.50', 1.5)).toBe('1.50'); + }); + + test('归一化先行:首尾零/符号/科学计数法', () => { + expect(helper.shiftDecimal('007.50', 0)).toBe('7.50'); + expect(helper.shiftDecimal('+7', 0)).toBe('7'); + expect(helper.shiftDecimal('-0.000', 0)).toBe('0.000'); // 负零坍缩为无符号 + expect(helper.shiftDecimal('1e-7', 0)).toBe('0.0000001'); + expect(helper.shiftDecimal('1.23e5', 0)).toBe('123000'); + }); + + test('非法输入返回 null,空白串返回空串', () => { + expect(helper.shiftDecimal('abc', 2)).toBeNull(); + expect(helper.shiftDecimal('1.2.3', 2)).toBeNull(); + expect(helper.shiftDecimal('', 2)).toBe(''); + }); +}); + +describe('roundDecimal:绝对值四舍五入(AwayFromZero),字符串路径无浮点陷阱', () => { + + test('经典浮点陷阱对照:1.005 与 0.1×0.2 场景字符串路径给正确答案', () => { + expect(Number(1.005).toFixed(2)).toBe('1.00'); // 浮点路径错(冻结对照) + expect(helper.roundDecimal('1.005', 2)).toBe('1.01'); + expect(helper.roundDecimal('2.345', 2)).toBe('2.35'); + expect(helper.roundDecimal('2.344', 2)).toBe('2.34'); + }); + + test('负数远离零(与 ExitYtm 约定同向)', () => { + expect(helper.roundDecimal('-2.345', 2)).toBe('-2.35'); + expect(helper.roundDecimal('-2.5', 0)).toBe('-3'); + expect(helper.roundDecimal('-0.00005', 4)).toBe('-0.0001'); + }); + + test('进位链:跨数量级与跨整数位', () => { + expect(helper.roundDecimal('9.99', 1)).toBe('10.0'); + expect(helper.roundDecimal('9.99', 0)).toBe('10'); + expect(helper.roundDecimal('99.999', 2)).toBe('100.00'); + expect(helper.roundDecimal('0.00005', 4)).toBe('0.0001'); + }); + + test('不补零也不主动去零:位数不足原样返回,已有位数保留尾巴零', () => { + expect(helper.roundDecimal('1.2', 4)).toBe('1.2'); // 不足4位不补零(补零是 formatCommon 的职责) + expect(helper.roundDecimal('3.10000', 4)).toBe('3.1000'); // 已到4位:只取整不去零(去零是 format 的职责) + }); + + test('防御姿态:负精度/非法输入原样返回,不做校验', () => { + expect(helper.roundDecimal('2.345', -1)).toBe('2.345'); + expect(helper.roundDecimal('2.345', 1.5)).toBe('2.345'); + expect(helper.roundDecimal('abc', 2)).toBe('abc'); + }); +}); + +describe('multiplyDecimal:字符串精确乘法', () => { + + test('0.1×0.2 精确为 0.02(浮点给 0.020000000000000004)', () => { + expect(helper.multiplyDecimal('0.1', '0.2')).toBe('0.02'); + expect(helper.multiplyDecimal('0.1', '0.1')).toBe('0.01'); + expect(helper.multiplyDecimal('1.1', '1.1')).toBe('1.21'); + }); + + test('符号组合;保留小数标度(1.5×2=3.0 而非 3)', () => { + expect(helper.multiplyDecimal('1.5', '2')).toBe('3.0'); + expect(helper.multiplyDecimal('-1.5', '2')).toBe('-3.0'); + expect(helper.multiplyDecimal('-1.5', '-2')).toBe('3.0'); + expect(helper.multiplyDecimal('-0.03', '-0.02')).toBe('0.0006'); + }); + + test('大整数精确(超出 Number.MAX_SAFE_INTEGER)', () => { + expect(helper.multiplyDecimal('123456789', '987654321')).toBe('121932631112635269'); + }); + + test('非法输入 null;空串按 0 处理', () => { + expect(helper.multiplyDecimal('abc', '1')).toBeNull(); + expect(helper.multiplyDecimal('', '1')).toBe('0'); + }); +}); + +describe('getRule / getInputFormat / roundForSubmit:字段规则解析与覆盖优先级', () => { + test('defaults 字段级规则:债券三字段 vs 股票兜底', () => { + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + expect(helper.getRule('Bond', 'grossPrice')).toEqual({ integerDigits: 6, precision: 9 }); + // 债券顶层没有 integerDigits/precision:字段拼错 → null(后续走 umprice 兜底), + // 而 Stock 顶层有 → 字段拼错回落品种级规则。这个不对称冻结于此。 + expect(helper.getRule('Bond', 'unknownField')).toBeNull(); + expect(helper.getRule('Stock', 'anything')).toEqual({ integerDigits: 7, precision: 2 }); + expect(helper.getRule('NoSuchType', 'yield')).toBeNull(); + }); + + test('main.swapPricePrecision 覆盖优先于 defaults,非法覆盖回落 defaults', () => { + withConfig({ Bond: { yield: { integerDigits: 3, precision: 5 } } }); + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 3, precision: 5 }); + + withConfig({ Bond: { yield: { integerDigits: 0 } } }); + const bad = helper; + expect(bad.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + + // defaults 里没有的品种,配置了也不认:覆盖只允许白名单内微调 + withConfig({ BrandNewType: { yield: { integerDigits: 3, precision: 5 } } }); + const unknown = helper; + expect(unknown.getRule('BrandNewType', 'yield')).toBeNull(); + }); + + test('getInputFormat 合并 options', () => { + expect(helper.getInputFormat('Bond', 'yield', { negative: true })) + .toEqual({ negative: true, integerDigits: 2, precision: 4 }); + expect(helper.getInputFormat('NoSuchType', 'yield', { negative: true })) + .toEqual({ negative: true }); + }); + + test('roundForSubmit:按规则精度+offset 取整;空值/无规则原样返回', () => { + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2)).toBe('3.141593'); + expect(helper.roundForSubmit('', 'Bond', 'yield')).toBe(''); + expect(helper.roundForSubmit('1.2', 'NoSuchType', 'yield')).toBe('1.2'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2.5)).toBe('3.1416'); // 非整数offset忽略 + }); +}); + +describe('format:字段级展示态(取整+去尾巴零)', () => { + test('按字段规则取整并去尾零', () => { + expect(helper.format('3.14159', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.format('3.10000', 'Bond', 'yield')).toBe('3.1'); + expect(helper.format('99.5', 'Stock', 'whatever')).toBe('99.5'); + expect(helper.format('', 'Bond', 'yield')).toBe(''); + }); + + test('无规则时回落 otcformat.trading.umprice', () => { + withConfig(undefined, { + otcformat: { trading: { umprice: function (v) { return 'UM:' + v; } } } + }); + expect(helper.format('1.23', 'NoSuchType', 'yield')).toBe('UM:1.23'); + }); +}); From 8b66875098d21ef5728305d904c52b766c2827f2 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:09:57 +0800 Subject: [PATCH 17/31] =?UTF-8?q?test(swap):=20EQD-6977=20=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E5=85=9C=E5=BA=95=E8=A1=A5share<1=E6=81=92=E7=AD=89?= =?UTF-8?q?=E5=BC=8F=E7=94=A8=E4=BE=8B=E2=80=94=E2=80=9450%=E5=B9=B3?= =?UTF-8?q?=E4=BB=93+=E6=97=A0preEod=E5=9C=BA=E6=99=AF=E9=92=89merger?= =?UTF-8?q?=E5=A4=8D=E5=88=BBGetInterests=E6=9C=AC=E9=87=91=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E6=8E=A5=E7=BC=9D(=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E6=9C=9F=E5=88=9D=E5=85=A8=E4=BB=B7CalcNotional=3DposiNotional?= =?UTF-8?q?=C3=97closePercent=20vs=20=E9=87=8D=E6=94=BE=E5=9F=BA=E6=95=B0?= =?UTF-8?q?=E6=8C=89closePosiNotionalValue=E7=BC=A9=E6=94=BE)=EF=BC=9B?= =?UTF-8?q?=E7=8E=B0=E6=9C=89=E7=94=A8=E4=BE=8B=E5=85=A8closePercent=3D1m?= =?UTF-8?q?=EF=BC=8C=E4=B8=A4=E5=A4=84=E5=8F=A3=E5=BE=84=E8=8B=A5=E6=9C=89?= =?UTF-8?q?=E5=81=8F=E5=B7=AE=E5=9C=A8share=3D1=E4=B8=8B=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E3=80=81share<1=E6=97=B6=E2=91=A0=E6=B7=B7=E5=85=A5?= =?UTF-8?q?=E6=9C=AC=E9=87=91=E5=B7=AE=EF=BC=9B=E5=8F=98=E5=BC=82=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=EF=BC=9ACalcNotional=E4=B8=B4=E6=97=B6=E5=BF=BD?= =?UTF-8?q?=E7=95=A5closePercent=E5=94=AF=E6=AD=A4=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E7=BA=A2(1=E8=B4=A57=E8=BF=87)=E8=AF=81=E6=98=8E=E9=89=B4?= =?UTF-8?q?=E5=88=AB=E5=8A=9B=EF=BC=9BRunFee=E5=8A=A0closePercent=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=BB=98=E8=AE=A41m=E5=AD=98=E9=87=8F=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=9B=B6=E5=BD=B1=E5=93=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyBoundaryMatrixTest.cs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs index 6ebb8bf0..bf3dd846 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -11,7 +11,9 @@ namespace UnitTestProject.Modules.SwapModule.Penalty /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); - /// ⑤ 起息日当天平仓(无 preEod)。 + /// ⑤ 起息日当天平仓(无 preEod); + /// ⑥ 部分平仓 share<1 + 无 preEod 兜底——钉 merger 复刻 GetInterests 本金口径的接缝 + /// (现有用例全部 closePercent=1m,重放基数与复刻本金的口径偏差在 share=1 下不可见)。 /// /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 @@ -67,7 +69,7 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static decimal RunFee(trade td, swap_position p, decimal settledAmount, eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, - decimal interestPrincipal = 0m, bool maturityCalcLast = true) + decimal interestPrincipal = 0m, bool maturityCalcLast = true, decimal closePercent = 1m) { var e = new swap_flow_event { @@ -78,7 +80,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty PenaltyInterestFeeMerger.Merge( td, new List { p }, new List { e }, unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, - posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + posiNotionalValue: Notional, closePosiNotionalValue: Notional * closePercent, + closePercent: closePercent, getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); return e.InterestFee; } @@ -192,6 +195,30 @@ namespace UnitTestProject.Modules.SwapModule.Penalty "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); } + [TestMethod] + public void 部分平仓无preEod兜底_share对齐本金口径_恒等式成立() + { + // 接缝守卫:merger 的 closePrincipal 走 CalcNotional 复刻 GetInterests 口径 + // (标的期初全价 = posiNotional×closePercent),而重放基数由调用方以 + // closePosiNotionalValue 缩放——两处口径若有偏差,share=1 时不可见、 + // share<1 时 ① 里会混入本金差。本用例以 50% 平仓钉死该对齐。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var share = 0.5m; + var closedNotional = Notional * share; + // 被平份额的实结与重放基数:复利对 notional 线性,直接按半额本金重放 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + var replayFinalBasis = closedNotional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, notional: closedNotional, period: 14); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false, closePercent: share); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "部分平仓+无preEod:兜底①按被平份额缩放精确,全期(被平份额)=实结+罚息(口径漂移时此式必挂)"); + } + [TestMethod] public void 起息日当天平仓_无preEod_恒等式成立() { From 6e76f1c10d1a7823696a8ba176ea2354ab7b6c57 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:10:04 +0800 Subject: [PATCH 18/31] =?UTF-8?q?fix(swap):=20EQD-6977=20=E6=97=A0preEod?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E2=91=A0=3D0=E4=B8=94=E8=B4=A6=E9=BE=84?= =?UTF-8?q?=E8=B6=85=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F=E8=A1=A5=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=91=8A=E8=AD=A6trace=E2=80=94=E2=80=94interestWindo?= =?UTF-8?q?wEmpty(=E5=BD=93=E6=97=A5=E5=B7=B2=E7=BB=93=E6=81=AF)=E6=97=A9?= =?UTF-8?q?=E9=80=80=E4=B8=8D=E9=87=8D=E6=94=BE=E8=A6=86=E7=9B=96=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E5=80=BC=E6=97=B6=E2=91=A0=E9=9D=99=E9=BB=980(?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E6=97=A5=E5=88=86=E6=94=AF=E6=9C=AC=E6=9C=89?= =?UTF-8?q?=E5=90=8C=E7=B1=BB=E5=91=8A=E8=AD=A6,=E6=AE=B5=E4=B8=AD?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=BC=8F)=E5=A4=8D=E5=88=A9=E6=AF=8F?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E5=B9=B6=E6=9C=AC=E7=90=86=E5=BA=94=E2=91=A0?= =?UTF-8?q?>0=EF=BC=9Btrace=E5=90=AB=E4=BA=8B=E4=BB=B6=E5=9F=BA=E6=95=B0/?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E6=9C=AC=E9=87=91=E4=B8=A4=E4=BE=A7=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=80=BC+=E8=B4=A6=E9=BE=84=E5=A4=A9=E6=95=B0+?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F+=E7=96=91=E4=BC=BC?= =?UTF-8?q?=E6=88=90=E5=9B=A0=E6=8C=87=E5=90=91(interestWindowEmpty?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E6=9C=AA=E9=87=8D=E6=94=BE/=E6=97=A5?= =?UTF-8?q?=E7=BB=88=E5=BD=92=E6=A1=A3=E7=BC=BA=E5=A4=B1)=E8=B6=B3?= =?UTF-8?q?=E5=A4=9F=E4=BA=8B=E5=90=8E=E5=AE=9A=E4=BD=8D=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=9B=E6=89=BF=E6=8E=A5=E5=9D=97=E6=8A=BD=E4=B8=BAResolveCo?= =?UTF-8?q?mpoundCarry=E7=A7=81=E6=9C=89=E7=BA=AF=E6=96=B9=E6=B3=95(Merge?= =?UTF-8?q?=E7=98=A6=E8=BA=AB,=E4=B8=89=E5=88=86=E6=94=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=A5=91=E7=BA=A6=E6=B3=A8=E9=87=8A=E9=9A=8F=E8=A1=8C?= =?UTF-8?q?,=E8=A1=8C=E4=B8=BA=E9=9B=B6=E5=8F=98=E5=8C=96)=EF=BC=9BTDD?= =?UTF-8?q?=E5=85=88=E8=A1=8C3=E4=BE=8Btrace=E9=92=89=E5=AD=90:=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=BF=85=E5=91=8A=E8=AD=A6(=E5=85=88=E7=BA=A2)/?= =?UTF-8?q?=E5=85=9C=E5=BA=95>0=E4=B8=8D=E5=91=8A=E8=AD=A6/=E7=9C=9F?= =?UTF-8?q?=E9=A6=96=E6=97=A5=E4=B8=8D=E5=91=8A=E8=AD=A6=EF=BC=9B=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E5=9F=9F27=E4=BE=8B+GetInterests=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=AF=AD=E4=B9=899=E4=BE=8B=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMergerTest.cs | 45 +++++++++- .../Penalty/PenaltyInterestFeeMerger.cs | 86 ++++++++++++------- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 2186bfdc..5df336c4 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static void RunMerge( swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, - Func? getSpread = null, Func? tryGetFixing = null) + Func? getSpread = null, Func? tryGetFixing = null, + AccrualTrace? trace = null) { getSpread ??= _ => Rate; tryGetFixing ??= (d, code) => Rate; @@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: getSpread, getPreEod: _ => preEod, - tryGetFixing: tryGetFixing); + tryGetFixing: tryGetFixing, + trace: trace); } /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 @@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); } + [TestMethod] + public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() + { + // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。 + // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化—— + // 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。 + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + StringAssert.Contains(trace.ToString(), "无preEod兜底①=0", + "①=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + } + + [TestMethod] + public void 无preEod兜底为正_不留退化告警() + { + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "①>0 是正常兜底路径,不得告警"); + } + + [TestMethod] + public void 无preEod真首日兜底为零_不留退化告警() + { + var p = Leg(InterestTypeEnum.复利); + p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注) + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; + var trace = new AccrualTrace(); + RunMerge(p, e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "真首日 ①=0 合法,不得告警"); + } + [TestMethod] public void 冻结利率解析失败_跳过该腿不阻断() { diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index a6b2256b..91ab256f 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -93,39 +93,9 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): - // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; - // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) - // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; - // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 - // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 - decimal capitalized = 0m, carryIn = 0m; - if (isCompound) - { - var periodDays = position.interest_rest_days ?? 1; - var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); - if (unwindOnResetDay) - { - capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; - if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) - trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); - } - else if (preEod != null) - { - capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); - } - else - { - capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); - } - // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) - if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) - { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); - capitalized = Math.Max(0m, normalEvent.InterestAmount); - } - carryIn = normalEvent.InterestAmount - capitalized; - } + var (capitalized, carryIn) = isCompound + ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) + : (0m, 0m); var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate @@ -149,4 +119,54 @@ public static class PenaltyInterestFeeMerger $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } + + /// + /// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。 + /// + /// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + /// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 + /// + private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry( + swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod, + decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace) + { + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + + var capitalized = 0m; + if (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty + // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 + var ageDays = (unwindDate - position.PosiStartDate).Days; + if (capitalized == 0m && ageDays >= periodDays) + trace?.Note( + $"PENALTY|p{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); + } + + // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + return (capitalized, normalEvent.InterestAmount - capitalized); + } } From e8d0a764422d0b8e39707169f3719e80a8a972f7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:17:20 +0800 Subject: [PATCH 19/31] =?UTF-8?q?refactor(web):=20=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E9=A1=B5=E6=94=B6=E4=BB=98=E6=96=B9=E5=90=91=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=8C=96unwindLegSign.js+4=E4=BE=8B=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E2=80=94=E2=80=94=E8=A3=B8=E4=B8=89=E5=85=83(x=3D=3D1?= =?UTF-8?q?=3F=C2=B11:=E2=88=931)=E6=8A=BD=E4=B8=BA=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=B9=B6=E5=86=BB=E7=BB=93=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E:=E5=88=A9=E6=81=AF=E7=9B=88=E4=BA=8F?= =?UTF-8?q?=E4=B8=8E=E4=BF=9D=E8=AF=81=E9=87=91=E8=BF=94=E8=BF=98=E6=9C=AC?= =?UTF-8?q?=E9=87=91=E5=AF=B9=E5=90=8C=E4=B8=80InterestDirection=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E5=BF=85=E9=A1=BB=E5=8F=8D=E5=8F=B7(=E5=88=A9?= =?UTF-8?q?=E6=81=AF=3D=E4=B9=B0=E6=96=B9=E6=8C=81=E6=9C=89=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E5=90=91=E4=BA=A4=E6=98=93=E5=95=86=E8=9E=8D=E8=B5=84?= =?UTF-8?q?=E7=9A=84=E6=88=90=E6=9C=AC,=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E8=BF=94=E8=BF=98=3D=E9=80=80=E5=AE=A2=E6=88=B7=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E4=BA=A4=E7=9A=84=E9=92=B1,=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=9D=9E=E7=AC=94=E8=AF=AF),=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=96=AD=E8=A8=80=E4=B8=A4=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E4=B9=8B=E5=92=8C=E6=81=920,=E9=A1=BA=E6=89=8B=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=BF=85=E6=8C=82=EF=BC=9BinterestPnlSign=E5=AF=B9?= =?UTF-8?q?=E5=88=A9=E6=81=AF/=E4=BF=9D=E8=AF=81=E9=87=91=E4=B8=A4?= =?UTF-8?q?=E8=85=BF=E9=80=9A=E7=94=A8(changeInterestAmount=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=AF=B9=E4=B8=A4=E7=B1=BB=E8=A1=8C=E9=83=BD=E7=BB=91?= =?UTF-8?q?=E5=AE=9A,=E5=B7=B2=E6=A0=B8=E5=AE=9E)=EF=BC=9B=E5=8F=A6?= =?UTF-8?q?=E5=88=A0=E6=AD=BB=E4=BB=A3=E7=A0=81:this.ratio/this.shortRatio?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=9E=E6=80=A7+=E8=B5=8B=E5=80=BC?= =?UTF-8?q?=E5=85=A8=E5=BA=93=E6=97=A0=E8=AF=BB=E5=8F=96(=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BA=A6=E6=97=A0=E7=BB=91=E5=AE=9A)=E5=8F=8A?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=8E=89=E7=9A=84=E5=8E=86=E5=8F=B2=E4=B8=89?= =?UTF-8?q?=E5=85=83=EF=BC=9BSwapUnwind.cshtml=E8=A1=A5script=E6=A0=87?= =?UTF-8?q?=E7=AD=BE,=E4=B8=A4=E4=B8=AA=E6=89=A7=E8=A1=8C=E5=9E=8B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=B2=99=E7=AE=B1=E5=96=82=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=9B=E8=A1=8C=E4=B8=BA=E7=AD=89=E4=BB=B7?= =?UTF-8?q?(Number=3D=3D=3D1=E4=B8=8E=3D=3D1=E5=AF=B9=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=85=A5=E5=8F=82=E7=AD=89=E4=BB=B7=E5=B7=B2=E9=AA=8C=E8=AF=81?= =?UTF-8?q?),=E5=85=A8=E5=A5=97315=E4=BE=8B=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml | 1 + .../closePercentInterestRefresh.test.js | 2 + YLErpWeb/fe-tests/unwindLegSign.test.js | 47 +++++++++++++++++++ YLErpWeb/fe-tests/unwindSwapTrade.test.js | 2 + .../Scripts/app/swaptrade/unwindLegSign.js | 45 ++++++++++++++++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 18 +++---- 6 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 YLErpWeb/fe-tests/unwindLegSign.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index c1fdaf8d..353c255e 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -27,6 +27,7 @@ + }
diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js index 2fb4a7eb..5d31102f 100644 --- a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -155,6 +155,8 @@ function loadVueApp(model, mockPost) { roundExitYtm(v) { return v; }, applyManualEdit(s) { return s; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/fe-tests/unwindLegSign.test.js b/YLErpWeb/fe-tests/unwindLegSign.test.js new file mode 100644 index 00000000..3258511c --- /dev/null +++ b/YLErpWeb/fe-tests/unwindLegSign.test.js @@ -0,0 +1,47 @@ +/** + * unwindLegSign.test.js — 平仓页收付方向符号映射(隐式约定显式化) + * ============================================================================ + * 冻结的领域事实(2026-08-21 业务确认): + * 利息腿(融资成本)与保证金腿(返息/返还本金)方向【必须相反】—— + * 利息是买方持有标的向交易商融资的成本(买方付出去的钱); + * 保证金是客户自己交的抵押金,返息/返还是把客户自己的钱退回来。 + * 两者对同一 InterestDirection 枚举符号互为镜像,这是业务事实而非笔误, + * 任何人"顺手统一"这两个符号都会翻转保证金返还方向(资损级 bug)。 + * + * 另冻结:PayDirection==1 → +1、PositionType==1(多头) → +1 的浮动端/多空符号。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindLegSign + */ +const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'); + +describe('收付方向符号:各枚举映射', () => { + test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => { + expect(UnwindLegSign.interestPnlSign(1)).toBe(1); + expect(UnwindLegSign.interestPnlSign(0)).toBe(-1); + expect(UnwindLegSign.interestPnlSign(null)).toBe(-1); + }); + + test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => { + expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1); + expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1); + expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1); + }); + + test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => { + expect(UnwindLegSign.payDirectionSign(1)).toBe(1); + expect(UnwindLegSign.payDirectionSign(0)).toBe(-1); + expect(UnwindLegSign.positionTypeSign(1)).toBe(1); + expect(UnwindLegSign.positionTypeSign(0)).toBe(-1); + }); +}); + +describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => { + test('同一 InterestDirection 下两符号之和恒为 0', () => { + [1, 0, null, undefined, 2].forEach(dir => { + expect( + UnwindLegSign.interestPnlSign(dir) + + UnwindLegSign.marginRebatePrincipalSign(dir) + ).toBe(0); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index f0774f96..488a0c3b 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -66,6 +66,8 @@ function loadUnwindHelpers() { roundHalfAwayFromZero(value) { return value; }, calcCloseQtyByOriginalPercent() { return 0; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js new file mode 100644 index 00000000..a07d62cd --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js @@ -0,0 +1,45 @@ +/** + * unwindLegSign.js — 平仓页收付方向符号(纯函数) + * ============================================================================ + * 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。 + * 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反—— + * 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug): + * 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效): + * 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱), + * InterestDirection==1(收取) → 盈亏记 +1; + * 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来, + * 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。 + * + * 其余两个符号:PayDirection==1(浮动端收取) → +1;PositionType==1(多头) → +1。 + * + * 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Node:module.exports 供 jest。 + */ +var UnwindLegSign = (function () { + 'use strict'; + + // Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支 + function interestPnlSign(interestDirection) { + return Number(interestDirection) === 1 ? 1 : -1; + } + + function marginRebatePrincipalSign(interestDirection) { + return Number(interestDirection) === 1 ? -1 : 1; + } + + function payDirectionSign(payDirection) { + return Number(payDirection) === 1 ? 1 : -1; + } + + function positionTypeSign(positionType) { + return Number(positionType) === 1 ? 1 : -1; + } + + return Object.freeze({ + interestPnlSign: interestPnlSign, + marginRebatePrincipalSign: marginRebatePrincipalSign, + payDirectionSign: payDirectionSign, + positionTypeSign: positionTypeSign + }); +}()); + +if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 764e9623..40b5da7c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -69,8 +69,6 @@ const vue = new Vue({ // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, - ratio: 1, - shortRatio: 1, }, computed: { maxUnwindDate() { @@ -125,8 +123,6 @@ const vue = new Vue({ this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; }); - this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; - this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) @@ -424,8 +420,8 @@ const vue = new Vue({ }, calcFloatClosePnl() {//计算浮动端平仓盈亏 var thisObj = this; - let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; - let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection); + let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let deliveryPrice = thisObj.getStorageDeliveryPrice(); @@ -440,7 +436,7 @@ const vue = new Vue({ this.calcFloatClosePnl(); }, changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; + let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection); item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio); this.calcCloseAmount(); }, @@ -450,8 +446,8 @@ const vue = new Vue({ // this.calcCloseAmount(); //}, calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1; - let ratio = this.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection); + let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType); let thisObj = this; let pnl = parseFloat(this.floatPosition.FloatPnlSum); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); @@ -468,14 +464,14 @@ const vue = new Vue({ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; } this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ let interestAmount = parseFloat(x.InterestClosePnL); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; }); this.marginList.forEach(x => { let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; + // 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一) + let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; From 771bd6798b74e41c490924e04695f76f5df64aca Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 08:38:42 +0800 Subject: [PATCH 20/31] =?UTF-8?q?feat(swap):=20EQD-6977=20=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E6=89=BF=E6=8E=A5=E2=91=A0=E4=B8=89=E5=88=86=E6=94=AF?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E7=A8=8B=E6=8E=A8=E5=AF=BCtrace=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=AE=B5=E4=B8=AD=E5=B9=B3=E4=BB=93+=E6=9C=89?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E4=B8=BB=E8=B7=AF=E5=BE=84=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E9=9B=B6=E7=95=99=E7=97=95(=E7=94=A8=E6=88=B7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E5=AE=9A=E4=BD=8D=E9=9A=BE)=EF=BC=8C=E7=8E=B0?= =?UTF-8?q?=E6=89=93=E5=87=BA=E5=BF=AB=E7=85=A7=E6=97=A5=E6=9C=9F/TdIntere?= =?UTF-8?q?stPrincipal/share/=E5=B9=B3=E4=BB=93=E6=9C=AC=E9=87=91/?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=B7=AE/=E9=92=B30=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=EF=BC=8C=E5=8E=9F=E5=A7=8B=E5=B7=AE=E4=B8=BA=E8=B4=9F=E9=A2=9D?= =?UTF-8?q?=E5=A4=96=E6=8F=90=E7=A4=BA=E7=96=91=E5=8F=A3=E5=BE=84=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=9B=E9=87=8D=E7=BD=AE=E6=97=A5+?= =?UTF-8?q?=E6=9C=89=E5=BF=AB=E7=85=A7=E8=A1=A5InterestIncomeSum=C3=97shar?= =?UTF-8?q?e=E6=8E=A8=E5=AF=BC=EF=BC=9B=E6=97=A0=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E8=A1=A5=E4=BA=8B=E4=BB=B6=E5=9F=BA=E6=95=B0?= =?UTF-8?q?-=E5=B9=B3=E4=BB=93=E6=9C=AC=E9=87=91=E6=8E=A8=E5=AF=BC?= =?UTF-8?q?=EF=BC=9B=E2=91=A0=E5=BC=82=E5=B8=B8=E5=87=AD=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8D=B3=E5=8F=AF=E5=8C=BA=E5=88=86=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9F=BA=E6=95=B0/=E4=BB=BD=E9=A2=9D/=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E6=9C=AC=E9=87=91=E4=B8=89=E5=9B=A0=EF=BC=9B27?= =?UTF-8?q?=E4=B8=AA=E7=BD=9A=E6=81=AF=E5=8D=95=E6=B5=8B=E5=85=A8=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMerger.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 91ab256f..aa0b4ce0 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -141,16 +141,29 @@ public static class PenaltyInterestFeeMerger if (unwindOnResetDay) { capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod != null) + trace?.Note( + $"PENALTY|p{position.id} 承接①推导 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → ①={capitalized:F4}"); if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); } else if (preEod != null) { - capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + // 段中平仓+有快照(复利承接主路径):① = 快照滚动基数×份额 − 平仓本金。全程留推导—— + // ①异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 + var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal; + capitalized = Math.Max(0m, rawCarry); + trace?.Note( + $"PENALTY|p{position.id} 承接①推导 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → ①={capitalized:F4}" + + (rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal)" : "")); } else { capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + trace?.Note( + $"PENALTY|p{position.id} 承接①推导 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → ①={capitalized:F4}"); // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 var ageDays = (unwindDate - position.PosiStartDate).Days; From 9d68e56797005e71dca1a1c6a034d88f7185a88f Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 08:41:51 +0800 Subject: [PATCH 21/31] =?UTF-8?q?refactor(swap):=20EQD-6977=20=E7=BD=9A?= =?UTF-8?q?=E6=81=AFtrace=E8=85=BF=E6=A0=87=E8=AF=86p{id}=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=9E=8D=E8=B5=84=E8=85=BF{id}=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8E=9F=E7=BC=A9=E5=86=99p=E5=8F=AF=E8=AF=BB=E6=80=A7?= =?UTF-8?q?=E5=B7=AE(=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88)=EF=BC=8C?= =?UTF-8?q?=E5=85=A8=E6=96=87=E4=BB=B69=E5=A4=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=EF=BC=9B=E6=97=A0=E6=B5=8B=E8=AF=95=E6=96=AD?= =?UTF-8?q?=E8=A8=80/=E5=A4=96=E9=83=A8=E6=B6=88=E8=B4=B9=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E8=AF=A5=E5=AD=97=E7=AC=A6=E4=B8=B2=EF=BC=8C27?= =?UTF-8?q?=E4=B8=AA=E7=BD=9A=E6=81=AF=E5=8D=95=E6=B5=8B=E5=85=A8=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMerger.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index aa0b4ce0..54289c5b 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -52,7 +52,7 @@ public static class PenaltyInterestFeeMerger var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); if (normalEvent == null) { - trace?.Note($"PENALTY|p{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); + trace?.Note($"PENALTY|融资腿{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); continue; } @@ -88,7 +88,7 @@ public static class PenaltyInterestFeeMerger } catch (Exception ex) { - trace?.Note($"PENALTY|p{position.id} 跳过 冻结利率解析失败:{ex.Message}"); + trace?.Note($"PENALTY|融资腿{position.id} 跳过 冻结利率解析失败:{ex.Message}"); continue; } @@ -111,7 +111,7 @@ public static class PenaltyInterestFeeMerger normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection); trace?.Note( - $"PENALTY|p{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + + $"PENALTY|融资腿{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + @@ -143,10 +143,10 @@ public static class PenaltyInterestFeeMerger capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; if (preEod != null) trace?.Note( - $"PENALTY|p{position.id} 承接①推导 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"PENALTY|融资腿{position.id} 承接①推导 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → ①={capitalized:F4}"); if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) - trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); } else if (preEod != null) { @@ -155,7 +155,7 @@ public static class PenaltyInterestFeeMerger var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal; capitalized = Math.Max(0m, rawCarry); trace?.Note( - $"PENALTY|p{position.id} 承接①推导 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → ①={capitalized:F4}" + (rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal)" : "")); } @@ -163,13 +163,13 @@ public static class PenaltyInterestFeeMerger { capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); trace?.Note( - $"PENALTY|p{position.id} 承接①推导 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → ①={capitalized:F4}"); + $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → ①={capitalized:F4}"); // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 var ageDays = (unwindDate - position.PosiStartDate).Days; if (capitalized == 0m && ageDays >= periodDays) trace?.Note( - $"PENALTY|p{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"PENALTY|融资腿{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); } @@ -177,7 +177,7 @@ public static class PenaltyInterestFeeMerger // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); capitalized = Math.Max(0m, normalEvent.InterestAmount); } return (capitalized, normalEvent.InterestAmount - capitalized); From 640c78b5a96e893c52feef2b5e20d13693879391 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 08:54:47 +0800 Subject: [PATCH 22/31] =?UTF-8?q?refactor(swap):=20EQD-6977=20trace?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7=E7=AC=AC=E4=BA=8C=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E2=91=A0=E2=91=A1=E4=B8=8Ep{id}=E8=AF=AD=E4=B9=89=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=89=BF=E6=8E=A5=E2=91=A0=E2=86=92=E5=B7=B2?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E5=88=A9=E6=9C=AC=E9=87=91(capitalized)?= =?UTF-8?q?=E3=80=81=E2=91=A1=E2=86=92=E6=AE=B5=E5=86=85=E5=B7=B2=E8=AE=A1?= =?UTF-8?q?=E5=88=A9=E6=81=AF(carryIn)=EF=BC=8C=E6=97=A5=E5=BF=97/?= =?UTF-8?q?=E7=B1=BB=E5=A4=B4=E6=B3=A8/=E6=96=B9=E6=B3=95=E6=B3=A8/?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80=E5=9B=9B=E5=A4=84=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8C=E8=BF=90=E7=BB=B4=E5=87=AD=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=8D=B3=E7=9F=A5=E4=B8=A4=E6=89=BF=E6=8E=A5=E9=87=8F=E5=90=AB?= =?UTF-8?q?=E4=B9=89=EF=BC=9BSwapDealService=C3=9712+SwapEodPositionServic?= =?UTF-8?q?e=C3=971=E7=9A=84p{position.id}=E7=BB=9F=E4=B8=80=E6=94=B9?= =?UTF-8?q?=E8=9E=8D=E8=B5=84=E8=85=BF{position.id}(FIX=20GetFloatRate/Res?= =?UTF-8?q?olve/Segments/EodCloseRefix=E5=85=A8=E7=B3=BB=E5=88=97)?= =?UTF-8?q?=EF=BC=9B=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0(=E6=AD=A4=E5=89=8D=E6=BC=8F=E6=9F=A5?= =?UTF-8?q?=E5=88=B0=E4=B8=80=E5=A4=84trace=E6=96=AD=E8=A8=80=E5=B7=B2?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E6=94=B9)=EF=BC=9B27/27=E7=BD=9A=E6=81=AF?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E9=80=9A=E8=BF=87=EF=BC=8CSwap=E5=85=A8?= =?UTF-8?q?=E5=A5=97644=E4=BE=8B=E4=B8=AD=E4=BB=852=E4=BE=8BDB=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=A4=B1=E8=B4=A5(=E5=B9=B2=E5=87=80=E6=A0=91?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E4=B8=BA=E6=97=A2=E6=9C=89=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E9=97=AE=E9=A2=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMergerTest.cs | 16 +++---- .../Penalty/PenaltyInterestFeeMerger.cs | 44 +++++++++---------- .../Modules/SwapModule/SwapDealService.cs | 24 +++++----- .../SwapModule/SwapEodPositionService.cs | 2 +- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 5df336c4..3a9ede19 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -157,40 +157,40 @@ namespace UnitTestProject.Modules.SwapModule.Penalty [TestMethod] public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() { - // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。 - // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化—— + // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底已并复利本金=0。 + // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应>0,已并复利本金=0 属退化—— // 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。 var e = NormalEvent(settledAmount: 50_000m); e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它 var trace = new AccrualTrace(); RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); - StringAssert.Contains(trace.ToString(), "无preEod兜底①=0", - "①=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0", + "已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); } [TestMethod] public void 无preEod兜底为正_不留退化告警() { var e = NormalEvent(settledAmount: 50_000m); - e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径 + e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → 已并复利本金=100000 正常路径 var trace = new AccrualTrace(); RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); - Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "①>0 是正常兜底路径,不得告警"); + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警"); } [TestMethod] public void 无preEod真首日兜底为零_不留退化告警() { var p = Leg(InterestTypeEnum.复利); - p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注) + p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,已并复利本金=0 是设计内约定(类头注) var e = NormalEvent(settledAmount: 50_000m); e.InterestPrincipal = Notional; var trace = new AccrualTrace(); RunMerge(p, e, preEod: null, trace: trace); - Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "真首日 ①=0 合法,不得告警"); + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警"); } [TestMethod] diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 54289c5b..59b64956 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -12,9 +12,9 @@ namespace YLErp.Modules.SwapModule.Penalty; /// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 /// /// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: -/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; -/// 承接② carryIn = 正常平仓流实结 InterestAmount − ① —— 最近重置日后实际已计利息; -/// 无 preEod(首日平仓):①=0、②=实结金额。 +/// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; +/// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount − 已并复利本金 —— 最近重置日后实际已计利息; +/// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。 /// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 ///
public static class PenaltyInterestFeeMerger @@ -48,7 +48,7 @@ public static class PenaltyInterestFeeMerger foreach (var position in fundingPositions) { - // 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 + // 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标 var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); if (normalEvent == null) { @@ -92,7 +92,7 @@ public static class PenaltyInterestFeeMerger continue; } - // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 + // 复利承接:实际滚动基数中已并入部分(已并复利本金)+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。 var (capitalized, carryIn) = isCompound ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) : (0m, 0m); @@ -115,18 +115,18 @@ public static class PenaltyInterestFeeMerger $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + - $"承接①={capitalized:F4} ②={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + + $"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } /// - /// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。 + /// 复利承接量:已并复利本金(实际滚动基数中已并入部分)+ 段内已计利息(最近重置日后实际已计,= 实结 − 已并复利本金)。 /// - /// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): - /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + /// 已并复利本金的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+已并复利本金),直接作差; /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) - /// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + /// 会把它写为末次并本金后的基数(=被平份额本金+已并复利本金),同样是实际值而非推导值; /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 /// @@ -143,41 +143,41 @@ public static class PenaltyInterestFeeMerger capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; if (preEod != null) trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + - $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → ①={capitalized:F4}"); + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → 已并复利本金={capitalized:F4}"); if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) - trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:已并复利本金退化0(此前重置并入额缺失,请核对日终归档完整性)"); } else if (preEod != null) { - // 段中平仓+有快照(复利承接主路径):① = 快照滚动基数×份额 − 平仓本金。全程留推导—— - // ①异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 + // 段中平仓+有快照(复利承接主路径):已并复利本金 = 快照滚动基数×份额 − 平仓本金。全程留推导—— + // 结果异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal; capitalized = Math.Max(0m, rawCarry); trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + - $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → ①={capitalized:F4}" + + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → 已并复利本金={capitalized:F4}" + (rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal)" : "")); } else { capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → ①={capitalized:F4}"); - // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → 已并复利本金={capitalized:F4}"); + // 兜底已并复利本金=0 但账龄已过重置周期:复利每周期并本,理应 >0——多为 interestWindowEmpty // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 var ageDays = (unwindDate - position.PosiStartDate).Days; if (capitalized == 0m && ageDays >= periodDays) trace?.Note( - $"PENALTY|融资腿{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"PENALTY|融资腿{position.id} 注意 无preEod兜底已并复利本金=0但账龄{ageDays}天≥重置周期{periodDays}天:" + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); } - // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + // 已并复利本金不得超过实结金额(数据异常时钳制并留痕,避免负的段内已计利息进入计息) if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) { - trace?.Note($"PENALTY|融资腿{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 承接已并复利本金钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); capitalized = Math.Max(0m, normalEvent.InterestAmount); } return (capitalized, normalEvent.InterestAmount - capitalized); diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 0a238637..25376977 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -905,13 +905,13 @@ namespace YLErp.Modules.SwapModule isResetDay ? endDate : startDate, position.interest_rule); // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + + $"FIX GetFloatRate 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " + $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "无")}"); if (preEod.id != 0 && !isResetDay) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); position.FloatRate = positionClone.FloatRate = preEod.FloatRate; return preEod.FloatRate; } @@ -923,13 +923,13 @@ namespace YLErp.Modules.SwapModule { var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return keptNoFetch; } if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } @@ -937,17 +937,17 @@ namespace YLErp.Modules.SwapModule { if (calcLast) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); } // 算头不算尾(calcLast=false):endDate 当天不计息,其 FR007 利率不参与计息, // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return kept; } - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 计息窗口为空→利率不参与,返回0"); return 0m; } @@ -1376,15 +1376,15 @@ namespace YLErp.Modules.SwapModule if (fixing != 0m) { SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); return fixing; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); return fallback; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } @@ -1403,7 +1403,7 @@ namespace YLErp.Modules.SwapModule var calcDays = (endDate - startDate).Days; decimal currentFloat = initialFloat; SwapCalcTrace.Critical( - $"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + + $"FIX Segments 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + $"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " + $"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "无" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}"); for (int i = 0; i <= calcDays; i += interestPeriod) @@ -1422,7 +1422,7 @@ namespace YLErp.Modules.SwapModule else if (needFetch && isExcludedEnd) { SwapCalcTrace.Critical( - $"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); + $"FIX Segment 融资腿{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); } rates.Add((resetDate, spread + currentFloat)); } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 714ba36d..f81b2f7f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1380,7 +1380,7 @@ namespace YLErp.Modules.SwapModule { var ongoingFixing = ResolveOngoingResetFixing(position, valueDate); SwapCalcTrace.Critical( - $"FIX EodCloseRefix p{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); + $"FIX EodCloseRefix 融资腿{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); newEodPayPosition.FloatRate = ongoingFixing; } //利息端估值用信息 From 433bca953065970910247a1d825cab3be5807dc0 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 09:16:28 +0800 Subject: [PATCH 23/31] =?UTF-8?q?fix(web):=20EQD-5320=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=8F=91=E9=80=81=E7=A1=AE=E8=AE=A4=E4=B9=A6=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=97=A0=E5=8F=8D=E5=BA=94=E2=80=94=E2=80=94?= =?UTF-8?q?$.ajax=E5=8E=9F=E6=97=A0error=E5=9B=9E=E8=B0=83=EF=BC=8C/trs=5F?= =?UTF-8?q?hub=5Fapi=E4=BB=A3=E7=90=86=E4=B8=8D=E9=80=9A/=E8=B6=85?= =?UTF-8?q?=E6=97=B6/=E8=BF=94=E5=9B=9E=E9=9D=9EJSON(=E5=A6=82=E8=A2=AB?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E9=87=8D=E5=AE=9A=E5=90=91)?= =?UTF-8?q?=E6=97=B6=E5=AE=8C=E5=85=A8=E9=9D=99=E9=BB=98=EF=BC=9B=E8=A1=A5?= =?UTF-8?q?error=E5=9B=9E=E8=B0=83=E7=BB=99HTTP=E7=8A=B6=E6=80=81+?= =?UTF-8?q?=E6=8E=92=E6=9F=A5=E6=8C=87=E5=BC=95(=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=E5=90=8C=E6=AD=A5=E8=AF=A6=E9=94=99)=EF=BC=8Csuccess?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E9=9D=9E=E6=A0=87=E5=87=86=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=B9=B6=E6=8F=90=E7=A4=BA=E7=AC=94=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E6=88=90=E5=8A=9F=E5=90=8E=E5=88=B7=E6=96=B0=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=9B232/232=E5=89=8D=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/app/swaptrade/tradeEndConfirmList.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js index 6f82f664..90eb900d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js @@ -176,12 +176,22 @@ function SendEmailEitherReport() { dataType: "json", contentType:"application/json", success: function (res) { - if (res.success) { - main.message("发送成功"); + // 代理/网关异常时可能返回非标准结构——统一兜底提示,绝不让点击"无反应" + if (res && res.success) { + main.message("发送成功(共" + selIds.length + "笔),详情见bond-oms日志[结算确认书邮件]"); + SearchClick(); // 刷新列表以反映发送状态 } else { - main.message(res.message); + var msg = (res && (res.message || res.msg)) || "发送失败:服务返回异常结构"; + main.message(msg); + console.error("[批量发送确认书] 业务失败:", res); } }, + error: function (xhr, textStatus, errThrown) { + // 请求层失败:代理不通/超时/返回非JSON(如被登录页重定向)——此前无error回调导致点击"毫无反应" + var detail = "HTTP " + xhr.status + " " + (textStatus || "") + (errThrown ? " " + errThrown : ""); + main.message("发送请求失败(" + detail + "):请检查服务是否可用(含/trs_hub_api代理),详情见控制台与网络面板"); + console.error("[批量发送确认书] 请求失败:", detail, xhr); + }, beforeSend(jqXHR) { main.waitMe(true); }, From 00ae047e01469bc7b1b023701b49f0e64aad5781 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 09:24:23 +0800 Subject: [PATCH 24/31] =?UTF-8?q?docs(swap):=20EQD-5320=20EmailTemplateSer?= =?UTF-8?q?vice=E8=A1=A5=E8=AE=BE=E8=AE=A1=E5=AE=88=E5=8D=AB=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E2=80=94=E2=80=94=E5=8D=A0=E4=BD=8D=E7=AC=A6=E6=9C=B4?= =?UTF-8?q?=E7=B4=A0=E6=9B=BF=E6=8D=A2/=E4=B8=8D=E5=81=9AHTML=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=E5=9D=87=E4=B8=BA=E6=9C=89=E6=84=8F=E5=86=B3=E7=AD=96?= =?UTF-8?q?=EF=BC=8C=E4=B8=8Ebond-oms=20Java=20SwapEmailHandler=E5=90=8C?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=8B=BF=E5=8D=95=E8=BE=B9=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BC=95=E6=93=8E=E6=88=96=E5=8A=A0=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E6=96=B0=E5=8D=A0=E4=BD=8D=E7=AC=A6=E4=B8=A4?= =?UTF-8?q?=E4=BE=A7=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/AppModule/EmailTemplateService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/AppModule/EmailTemplateService.cs b/YLErpDAL/Modules/AppModule/EmailTemplateService.cs index c98fb9d8..aeca2cbc 100644 --- a/YLErpDAL/Modules/AppModule/EmailTemplateService.cs +++ b/YLErpDAL/Modules/AppModule/EmailTemplateService.cs @@ -74,7 +74,12 @@ namespace YLErp.Modules.AppModule } /// - /// 生成邮件信息,如果template为null,返回null + /// 生成邮件信息,如果template为null,返回null。 + /// 【设计决策·勿单边"升级"】占位符为朴素文本替换(非模板引擎),与本表另一消费者 + /// bond-oms Java SwapEmailHandler.replaceMailPlaceholders 保持同构渲染语义—— + /// 单边引入 FreeMarker/Thymeleaf 等更强语法或 HTML 转义,会造成两侧发出内容不一致。 + /// 替换值(客户名称/文档编号等)不做 HTML 转义是有意为之:均为内部维护的可信数据; + /// 若未来要拼接用户自由输入的内容,必须两侧同步加转义。新占位符同样两侧同步加白名单。 /// public static MailInfoResultModel GenerateMailInfo(EmailTemplate template, MailInfoRequestModel reqModel) { From 0567955434b1d2b881636b919c6b5d88e2c3a864 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 09:40:01 +0800 Subject: [PATCH 25/31] =?UTF-8?q?fix(swap):=20EQD-5320=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8F=91=E9=80=81=E7=A1=AE=E8=AE=A4=E4=B9=A6=E6=94=B9?= =?UTF-8?q?=E8=B5=B0C#=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8E=9F=E5=89=8D=E7=AB=AF=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?/trs=5Fhub=5Fapi=E5=8F=8D=E5=90=91=E4=BB=A3=E7=90=86=E5=9C=A8?= =?UTF-8?q?=E6=9C=AA=E9=85=8Dnginx=E7=9A=84=E7=8E=AF=E5=A2=83404(UAT?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B)=EF=BC=8C=E4=B8=8E=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E5=99=A8(SendConfirmEamil=E5=90=8C=E6=AC=BEBondOmsInterface=5F?= =?UTF-8?q?BaseUrl=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=87=BA=E5=8F=A3)=E9=80=9A?= =?UTF-8?q?=E8=B7=AF=E4=B8=8D=E4=B8=80=E8=87=B4=EF=BC=9B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?SwapEndConfirmService.BatchSendSettleEmail(POST=E4=BB=A3?= =?UTF-8?q?=E7=90=86bond-oms=20batchSend,=E5=A5=91=E7=BA=A6DTO+=E6=88=90?= =?UTF-8?q?=E8=B4=A5=E6=97=A5=E5=BF=97)=E4=B8=8ESwapTrade2Controller.Batch?= =?UTF-8?q?SendSettleEmail(=E9=80=97=E5=8F=B7id=E4=B8=B2=E9=98=B2=E5=91=86?= =?UTF-8?q?=E8=A7=A3=E6=9E=90)=EF=BC=9B=E5=89=8D=E7=AB=AF=E6=94=B9?= =?UTF-8?q?=E8=B0=83=E5=90=8C=E6=BA=90/swaptrade2=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E4=BF=9D=E7=95=99error=E5=85=9C=E5=BA=95=EF=BC=9B232/232?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwapModule/SwapEndConfirmService.cs | 42 +++++++++++++++++++ YLErpWeb/Controllers/SwapTrade2Controller.cs | 28 +++++++++++++ .../app/swaptrade/tradeEndConfirmList.js | 19 +++++---- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs index 05c0ec86..0517f9c3 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs @@ -256,5 +256,47 @@ namespace YLErp.Modules.SwapModule } return "未配置邮件接口地址"; } + + /// + /// EQD-5320 批量发送结算确认书邮件:服务端代理 bond-oms /swap/email/settle/batchSend。 + /// 前端原直连 /trs_hub_api 反向代理(依赖 nginx 配置,未配的环境 404)——统一改走本代理, + /// 与债券计算器/SendEmail 同一条 BondOmsInterface_BaseUrl 出口,不再依赖前端网关。 + /// 返回 空串=成功;非空=失败原因(透传 bond-oms message)。 + /// + public string BatchSendSettleEmail(List swapFlowEventIds) + { + var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl"); + if (string.IsNullOrEmpty(baseUrl)) + { + return "未配置邮件接口地址(BondOmsInterface_BaseUrl)"; + } + const string url = "/swap/email/settle/batchSend"; + var logger = LogFactory.GetLogger("SwapEndConfirm"); + var idsDesc = string.Join(",", swapFlowEventIds); + try + { + var result = new HttpHelper(baseUrl, null) + .PostRequestNoAuth(url, + new OmsSettleBatchSendReq { swapFlowEventIds = swapFlowEventIds }) + .Result; + logger.Info($"批量发送结算确认书: url={baseUrl}{url} ids=[{idsDesc}] → success={result?.success} message={result?.message}"); + if (result == null) + { + return "邮件服务无响应"; + } + return result.success ? "" : (result.message ?? "发送失败"); + } + catch (Exception ex) + { + logger.Error($"批量发送结算确认书异常: url={baseUrl}{url} ids=[{idsDesc}]", ex); + return "请求邮件服务异常:" + ex.GetBaseException().Message; + } + } + + /// bond-oms SettleEmailSendParam 契约(字段名须与 Java 端一致,Jackson 按 swapFlowEventIds 绑定) + private class OmsSettleBatchSendReq + { + public List swapFlowEventIds { get; set; } + } } } diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 33c82881..91512f91 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -1207,6 +1207,34 @@ namespace YLErp.Web.Controllers return JsonSuccess(result); } + /// + /// EQD-5320 批量发送结算确认书邮件——经服务端代理 bond-oms(BondOmsInterface_BaseUrl 出口), + /// 替代前端直连 /trs_hub_api 反向代理(未配 nginx 的环境 404)。 + /// + /// 平仓流水id,逗号分隔 + [HttpPost] + public JsonResult BatchSendSettleEmail(string swapFlowEventIds) + { + if (string.IsNullOrWhiteSpace(swapFlowEventIds)) + { + return JsonError("请选择交易"); + } + var ids = new List(); + foreach (var s in swapFlowEventIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (long.TryParse(s.Trim(), out var id)) + { + ids.Add(id); + } + } + if (ids.Count == 0) + { + return JsonError("平仓流水id解析为空"); + } + var result = new SwapEndConfirmService(CurUser).BatchSendSettleEmail(ids); + return string.IsNullOrEmpty(result) ? JsonSuccess("发送成功") : JsonError(result); + } + } } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js index 90eb900d..f6ee26e4 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js @@ -167,29 +167,30 @@ function SendEmailEitherReport() { main.message("请选择普通交易"); return; } - var url = "/trs_hub_api/swap/email/settle/batchSend"; - var postData = { swapFlowEventIds: selIds } + // EQD-5320: 改走本系统后端代理(/swaptrade2/BatchSendSettleEmail → BondOmsInterface_BaseUrl → bond-oms), + // 与债券计算器同一条服务端出口;原直连 /trs_hub_api 反向代理在未配 nginx 的环境 404 + var url = "/swaptrade2/BatchSendSettleEmail"; + var postData = { swapFlowEventIds: selIds.join(',') } $.ajax({ type: "post", url: url, - data: JSON.stringify(postData), + data: postData, dataType: "json", - contentType:"application/json", success: function (res) { - // 代理/网关异常时可能返回非标准结构——统一兜底提示,绝不让点击"无反应" + // 兜底非标准返回结构,绝不让点击"无反应" if (res && res.success) { - main.message("发送成功(共" + selIds.length + "笔),详情见bond-oms日志[结算确认书邮件]"); + main.message(res.msg || ("发送成功(共" + selIds.length + "笔)")); SearchClick(); // 刷新列表以反映发送状态 } else { - var msg = (res && (res.message || res.msg)) || "发送失败:服务返回异常结构"; + var msg = (res && (res.msg || res.message)) || "发送失败:服务返回异常结构"; main.message(msg); console.error("[批量发送确认书] 业务失败:", res); } }, error: function (xhr, textStatus, errThrown) { - // 请求层失败:代理不通/超时/返回非JSON(如被登录页重定向)——此前无error回调导致点击"毫无反应" + // 请求层失败:超时/返回非JSON(如被登录页重定向) var detail = "HTTP " + xhr.status + " " + (textStatus || "") + (errThrown ? " " + errThrown : ""); - main.message("发送请求失败(" + detail + "):请检查服务是否可用(含/trs_hub_api代理),详情见控制台与网络面板"); + main.message("发送请求失败(" + detail + "):请检查服务是否可用,详情见控制台与网络面板"); console.error("[批量发送确认书] 请求失败:", detail, xhr); }, beforeSend(jqXHR) { From fa927b21336b95613f4b8cd17c54f0b5ebf2af74 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 13:21:15 +0800 Subject: [PATCH 26/31] =?UTF-8?q?fix(zszq-trs):=20=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B9=A6=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=AF=8F=E8=A1=8C=E5=8F=91=E9=80=81=E9=82=AE=E4=BB=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=8C=E5=A4=8D=E7=94=A8=20BatchSendSettleEmail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 tradeEndConfirmSendEmail.js(独立文件降冲突),含纯函数 buildSettleEmailSendPayload 便于单测 - tradeEndConfirmList.js showToolName 加按钮,受 pageData.canSendEmail + ContractDocUrl 控制,跳过"多空组合" - cshtml 引入新脚本;后端零改动(复用 SwapEndConfirmService.BatchSendSettleEmail -> bond-oms Java) - 安全: 新按钮不引入新增 XSS/CSRF 暴露; CSRF token 缺失为全站既有问题,与批量按钮同源 --- .../SwapTrade2/tradeEndConfirmList.cshtml | 1 + .../app/swaptrade/tradeEndConfirmList.js | 5 +++ .../app/swaptrade/tradeEndConfirmSendEmail.js | 40 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js diff --git a/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml b/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml index 28fd4d8a..e9290c4e 100644 --- a/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml @@ -31,6 +31,7 @@ + }
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js index f6ee26e4..e329f789 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js @@ -26,6 +26,11 @@ function showToolName(cellValue, options, rowObject) { //var disabled = pageData.canGenerate == true ? "" : "disabled"; //html += "" // .template(rowObject.swap_flow_event.EncryptId, rowObject.swap_flow_event.PayDate, "修改支付日期", disabled); + if (rowObject.ContractDocUrl && pageData.canSendEmail) { + html += + "" + .template(rowObject.swap_flow_event.EncryptId, rowObject.trade.StructureType, "发送Email"); + } return html; } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js new file mode 100644 index 00000000..afcab928 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js @@ -0,0 +1,40 @@ +// 结算确认书:单行发送邮件(复用 /swaptrade2/BatchSendSettleEmail 服务端代理 → bond-oms Java 发信) +// 单独成文件,降低与 tradeEndConfirmList.js 的并发修改冲突;核心发送逻辑与网格渲染解耦,便于单测。 + +// 纯函数:根据加密平仓流水ID构造请求负载(不依赖 $.ajax / DOM),便于单元验证。 +function buildSettleEmailSendPayload(encryptId) { + return { url: "/swaptrade2/BatchSendSettleEmail", swapFlowEventIds: encryptId }; +} + +// 单行发送结算确认书邮件;encryptId 为空、或与批量 SendEmailEitherReport 一致地跳过"多空组合"时早退。 +function sendSettleEmail(encryptId, structureType) { + if (!encryptId) { + main.message("缺少平仓流水ID"); + return; + } + if (structureType === "多空组合") { + main.message("多空组合不支持发送结算确认书"); + return; + } + var payload = buildSettleEmailSendPayload(encryptId); + $.ajax({ + type: "post", + url: payload.url, + data: { swapFlowEventIds: payload.swapFlowEventIds }, + dataType: "json", + success: function (res) { + if (res && res.success) { + main.message(res.msg || "发送成功"); + SearchClick(); + } else { + main.message((res && (res.msg || res.message)) || "发送失败"); + } + }, + error: function (xhr) { + main.message("发送请求失败(HTTP " + xhr.status + "),请检查服务"); + console.error("[单行发送结算确认书] 请求失败:", xhr); + }, + beforeSend: function () { main.waitMe(true); }, + complete: function () { main.waitMe(false); } + }); +} From f16634534cb597538d8c3a1e2d1eaa897f1eaa29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E5=86=B0=E5=86=B0?= <437394478@qq.com> Date: Fri, 21 Aug 2026 14:16:05 +0800 Subject: [PATCH 27/31] =?UTF-8?q?feature=EF=BC=9A#EQD-7093=20=E5=9B=BD?= =?UTF-8?q?=E8=81=94=E6=B0=91=E7=94=9F-=E4=BA=A4=E6=98=93=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=B9=A6=E6=A0=A1=E9=AA=8C=EF=BC=882=EF=BC=89?= =?UTF-8?q?=E9=92=88=E5=AF=B9ETF=E6=A0=87=E7=9A=84=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BF=A1=E6=81=AF=E5=B9=B6=E4=B8=94=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=85=8D=E7=BD=AE=E5=88=86=E7=B1=BB=E3=80=82=20ETF?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E7=A1=AE=E8=AE=A4=E4=B9=A6=E4=B8=AD=E5=A1=AB?= =?UTF-8?q?=E5=85=85=E5=8F=82=E8=80=83=E6=A0=87=E7=9A=84=E8=AF=81=E5=88=B8?= =?UTF-8?q?=E5=85=A8=E7=A7=B0=E3=80=81=E5=8F=82=E8=80=83=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E5=9F=BA=E9=87=91=E7=AE=A1=E7=90=86=E4=BA=BA=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/underlying_manager.cs | 14 ++++++ .../Dictionary/db_dictionaries.xml | 13 ++++++ ...模板-【客户看多】-【债券ETF】-清洁版.docx | Bin 45307 -> 42742 bytes ...模板-【客户看空】-【债券ETF】-清洁版.docx | Bin 46671 -> 44477 bytes .../TradeConfirmationGenerator.cs | 3 +- .../UnderlyingFundManagerMappingTest.cs | 20 ++++++++ YLErpDAL/Modules/AppModule/AppUpgrader.cs | 43 ++++++++++++++++++ .../underlying_managerController.cs | 20 ++++++++ .../underlying_managerEdit.cshtml | 34 ++++++++++++-- .../underlying_managerView.cshtml | 10 +++- .../Scripts/app/underlying/underlyingedit.js | 16 +++++-- 11 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs diff --git a/Framework/YLErp.Core/DBModels/underlying_manager.cs b/Framework/YLErp.Core/DBModels/underlying_manager.cs index 2808b5f5..b1187b6e 100644 --- a/Framework/YLErp.Core/DBModels/underlying_manager.cs +++ b/Framework/YLErp.Core/DBModels/underlying_manager.cs @@ -137,6 +137,20 @@ namespace YLErp.DBModels [DisplayName("标的名称")] public string UnderlyingName { set; get; } + /// + /// 基金及基金专户的基金管理人名称 + /// + [Column("investadvisorname")] + [DisplayName("基金管理人")] + public string InvestAdvisorName { get; set; } + + /// + /// 基金及基金专户所属的 ETF 子类,保存“ETF 子类”字典项名称 + /// + [Column("etf_sub_type")] + [DisplayName("ETF 子类")] + public string EtfSubType { get; set; } + /// /// 标的英文名 diff --git a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml index 97770296..b56ab947 100644 --- a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml +++ b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml @@ -42,4 +42,17 @@ + + + + + + + + + + + + + diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx index 573b1546287882d6b50a218798ffb7be62a68bea..030e6c1c073433a804c76d2e0d2b6c82de800672 100644 GIT binary patch literal 42742 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
6n*L@x+q;pgd5G_KVKB1% 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%0AV;>0hJQ!=sQ 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 S!XLdCI$#tM02t5zarA%wPvfot literal 46671 zcmbq(W3VmVvgJP8XWO=I+qP}nwr!kk+qP}nw(UOOz5Tl1ebN8ABC2Xd#aKDBGDl=3 zrko@YFa*FqN2ts`-#_>NI)4BCVPtD4=U{8+NGtajhV=IV_`hJU-4q+vzW@My0098t z{yR+H&W^^-+A1rdPiBA)PUuGLQ!vTXfYf{)*g9Im4>3Rj`Z0{kbVH4(*%(vk2PZWj zp?cHBr0A|;#O{k7W)4h)v`J%FUE%;dc{WSw!}xP^n^YN#eMWf%tjZ1@cXnvy@*Z$m z1+!5nKT;sbVWRbATMK?)D3;LGT&<#Z&79K#w{Q!wYeU?>MWZvelxvJU3-R-@l;aY? z5hhADTow1bCoc;{l;>q0M+(4XcHa#2I-VQHKBRHM2iU`+*RUP@pL36FX?0RcHFQR?K5w!d>y?bt zvyR*qD%k;9v4@E1h?S4a>QPiy>4cdJk~C!F)XI7WS+lhyMh*FNR!Y1DB5-J1xlYY9 z*eJlX@mqwG>^0Q?bj^gu#jEUJ*SP(44g7z1jiIfB@jt!^PLP)zphF7Y0r?Re^UQE+ z+JLeKrSNpXQ$^jET{ki_q3J&M(TRzcorniK^%Y%`T)ywvKU`ZbCAobjfXv z?iBR2-#{D6qLmKUq~US4(525lXSd zqtxdc`5ELttuKq=EtLG%`qzK0kN6+fcXo8Lwf@KcO$pkz8FWY?Hc^X99<7E{Fg!ba_ z>A}Sa3%@ynUq7Us{SqusP&FoG`^_&))S9KG3zEYzmmnig03E%{0L)pmUS}-cNm!W# zgzcDRB|IWgl(W@9+^iWPPH>wIUrhzPH`?!62F$pT!*)B4iMzN%KUGl@l2-7%0DYy91ZFb0(97yhp5)m-|OM{DvEi+nvq%COul5CTd0`| zb|0)ar+iZ>)&m#oIyKRW!c-AyXs^QoqRBK7y04T8>6&axtFCZ`b8$vrQP9qJ=AMT1 zXxLdxzy5d;I^!gKQeqqK|KCv%Bz{s z#)&<21T?Bk1!kv$1Ll85IE=f*_p`n03CDdyB}ZA~R^nY9u;9de#hW>(h&4j)Az;63 ze_2z;a;J4q2_;C28g6kB!IAZX4;ni%di>mNg7+%Hp$QU2#j*l;Dzys@D{gV`Cg{TwkMfPbtnFxs9!id9< zDU?9l&qP}_q+~feBy4_IRE0=;Q2M!TfmokB8o-3eGvjzGj#V~1LM27Pc<>dHXR&M3 z*K1vIZ7Zn=fP1t16Xg=+YDK*~Kr=^djKhqk*;&>3N|b3#rDziZK)0TCOghmCcPFS) zJHBnv)ZQfVpy`vJVeMw!*#eDQ#ZO549E+n%LSWbc9^lBljk^^(3KjGdH?zh-;qb=5 zIrAv_1@Xg$ARg~oL74KP@AeBH5_BKVDvMj}-VQj2>d;q%caMq|=MbNfmuu-pyf6nf zr0;+ZTy*2*#@bMEyhkK)zTE#@v*}qdQ(#eM$qDG-Z@@{MyZ$w)d`g+~a}O_5H&*I( z;9d_7^nDaSI^&4b+qkTodC$fL8R$1rR=wl-{mKO?42o#a(C(?)^Ct94>qbZIr>2p7 zfPB<BEb8DFQm^;GsfLm%F8?r` z0f$a4no*ut6uz}b@*I+}ifgm~Y=;OoAdLmXYH!*a-cdy|iKfyZ7%cDy__LfaG>7%mh>o^6Iy8){?u}LO z?Y&&?V}xqS9#r4}lE9{&e$e5F_Q;axIS)`SikrV}fRU#7kVrNqQ|>0`7RWn@_S_J5 z7xS8$g*at9DcDx))kZ-Ht z*^06^Ta(lF6F4YtC5nwcAeZcBhNS)ue4L$Dx`>T`w4bP~&*uTYomL;FVN9Xna;Mr` zj4*`P@bQF zgvK~&MJ8^yj+~yc9sZ#)4R$=0Rc}RODpcsS&)E|HJP?NVTvb%{Dy8*fyOC zW1n?n>C)0!Kimd#g;5eRUY7&iO6Un3>UZ7m;F5I@m6A)F8E_UKMo_o!kA20a?BaU# zTwqy_ly*AnsfwXV!V$dyGBL1e&NNPFdqXan<#z&~S<~@x=>$1_L^zW@(@rE~+wOZ$ zob$CPRic26h_Q~Q75=A<%D?OUR9qQ>6;S# z%RCoO44oKQAq3n%>5d=I-R^znH6sxl{Ui3B)9;J)TN>}0X2ZAmYTgbR0KD)O-XDpV zAZ}k2Sm85_F9H`{?2ZtS+-KAC^zIGoo?N;cifyO_G;?3QZRf-uf>pBqVp%zy8%dVX zXABG0K?yF$)PUyZJ*@CRCMZiS8$b?~073%YO6?;tNp-)}%SMOWq_!_8gE9-uxJlV$ zhd$~(uintMZCW&FwvSWzsh{gIPOZo2%V3YsqQYn-0V$chf*mJ1W@<_FBUV&*s1-7ajeT;)d_4 zE$#~D;`4ynW?W)%xt;NjjZOCV*$ zFYQ|C{qb*r_*&>6=(iw4O5Ex{aYg;dkxel$#&vCm)TM&zRx)Lj7jiVH;n?F%b<6AF zS}VxSH2KY{yvPWQd&3yEH>x+uJAu(dK4f#l<-AE|hHtoHjp{gP5(bEQ64K5BXye9- z`4NxKuCvm8)tDdHR46vH!zG9(VEwuJ~-?}`&vmGb7>(%lr>tk&i1)Apr_>&FCzjUrg% zN3}b{G24MMGSc^@|B*_*t%Q={Y7xLe1Cl&03~x)3`rl+J5t!Z;Bd|yQ{hM@W84-(e zsZ%w{wh~PSi;`CH-zBZ?<>3DcCvrf&iNQU6mpQ;1${06rlvC_1`$yGq*5pWnzwrMp zBzaW5vrK6EuQGpiCWy~)QxqrLmp-aD7c46KpVA}|aQ}j-SiqS7k@z5!`uA1-0sqgp z{YOI*gtosc1^u(qU&YcbB3P40NnnxV$^Y8I_3pa0^f~_jU#(~%If=3&GwnTXi9yuyoA^vDObtq}nuYOvd;d(s;Qe2^sn@%ISg(<3`B3hc=1}tkk`hWsgw?3D`E1^OK6}nOBFLAqHEb5%^rC_Yo!E6}Hx6}FSSWoqvnZ5C% z(V2JbHZ!vCQFkeHd00R|Ik}wuh*za#*Kn|Ob>WnzJK}Bbs5@-$p0Sxa3;IBK43@Uk zlmln1tRB`X^Q41HKx+wys1)LXe%SZqACvU4kO_jH`MrfZ=ZZ{(?1qp?_8$biIkx$U ztG#&;L6C?qhfUq$QKnkZ{rnj*3v|XsTw+h7$26p8wn7%B?ztI3i@tpDCGYgbQdmj$ z`Zn&Vz1Mz@g|nbHbz`cs(|2tu^TB;3C+@E^B36_R6X(8Pn|UUfy8{b|1TlIXOkpj& z09&x2t!ZV|Jn-e#oN(n7cQIL+^|`r6-pPR-J<4AIf*|%e20f$kQ;xL9Nzpz+-6})9 zPti(7ehphAdz>n&#{p)C2hl$sF&b&G?iqhuFep?I%xcdrIP|BOjN`Lx-jQpALYF`4 zQhxKubQbT|#B|?V$`|JEE52g2*M!uhE3{yKcgOAA86i>Fm%eG?I<7q-WP|jY7k$*? zM8&2-krqVKg*bG~ypDfp4ML6goI?~<-+YC*TeSb?&_oGr^(qMX+oQD=X5C1 zyEdkl7azkZoqD@=gV@ndb4sTWoKx7OqVK!&W`Aq-Qpsb@YkOT z(qtP=Ea0P@LeR8|$roFnpKID5kB<}E?XK^s(3+pNcuqXoJU}0VGah|-r9K$wF_!H& z|DyL-rSC-P#EA%$wfYEW+IG=u&pVgC>);ph=Md0#;f@hL>1Nw&=y9y|4tev9VI`mq ze;TmrC&Q&mi;+SdpG+%2%l-GsAgB9pSzXRz#IB;+i8I(t8J4NqgJYd)INH`~2t`HR zER8E{(Jc+qz9YtEIzLrK$pQd*$N5)cZl`Cxu!~yH2UXqQ+Rple)`A&d%rSfNY61 z=?BhgJ94SqRx`QH@<_LBONO*6mN8+w<(34E05T6^1?!^p$d6hRWkocX`Ek zXI{)fl_~SP2{&3(ma-PJvc6LZY!~v{`9L1U;iqjp-HxGaf|N5olfqNMP;@-=Yp$|u z<+{z>@~5IT+`hg1{c9B-PKL)GC;W+KreiCq^*Cwxjr|NnqX=sKMJ}H-cko7-1a}$! zC8e-zS$Bw%Ey)IE1;k)DBab%M&3VmXyoIcCXT`I~pR!2+KU1AXZ3d?<8-=+@Qty(5 zv-d@pmEiO&Hx=f68$Ty_jhb`=&yU!h zK`y^%rJv%DExjllr8gA_HlSzGU-I!Q=#Et%HE0v)iZ@VjkddUbM96;KaVVComZtM+ zpmWtmim1)l( z2{QVOd1w(L&%=G?KQLIc8c{{{g?{-@A0yJ~J9V8ls;&UkZ$p=#hTZ{6JAjSG1YmVC zvdm3jhAAH@!gRA?J9f3v$>u90qjEn?NHWvHqEgcy7Q+~FpW#I^tQ;R~1p&xIDvx3l zHK*LQrIz6Ua6X^KmS4saDw^NflMWR`3E!1f2)jmE0bzv;e)WY}R8Bk5Ihgvk%#yf{ zoX_^8azEC_t0wsZ_TNwt?~I@Ct?p2i`f^{K8D(#UH{iU-yE?eDHYtOvg@fX8XH8e# z>!dnB7xpR1z8If1*KF%j((z}^Ameo0q=qozf%TG#g1LURg%p||%SGtw z3?-36_r`eOddl{-M6I3$xw+NUaY1=~-s9F9FZzr*0A_NzF9S_^*G`&+T;0UzjuSUe zn2z%h$EV4&30lLC`tS)+030_9Q*=w2uDXQe%){h>O3Vv#YXwWXCwr7f4_g!^bS8(a zM@zIvhg9=Z;1WP!0;?Vgglb9T^FdJ3JV%Hbr|bVMeAO!p*>^GE#p@G{@H!}LIZlAX zX?CP0!3g%0ZtT;mK!qdY_Gb@C{*M@3!2&%sn9rah;HlD_w%gD# z;7!9j%cNORN>n$STCc#aD6sTjBep7V?MOX+{DbI%4=96!Lm!%Pw@^1o`1YS@Q!-*( zi=qvP4y_yTDOq$TG&11;-B49pNV9aJj2OaHFSDGe!aA7+>|q8dB4i>3izoOIb7Y24 zH_G%U@YU2XSyVz-gZMM#80J{~v7%ivY)@zy<_HWU!X1+5b{GZ9V&MluW=9J9BIN6^ z3Nz8(kKcv<$3R%D`Ur#JRSqUXL5E5W1N% zsM|=o~YqomO z{$Nm`5$SV28aM-wd4G3b%~WNF%B&`>2A`hS!>#+pi|164nZR8S)Wdr}`2Oct(FLOL z;QMc1*Xi$<5#hhueI~ZHPBykq|L*uTC5_o^(7}b=fV{&1-YoAA^HZu#i}ZwPR!hGcBC2qDSnoN!@VIOVve5E;P==`= zJ3ebW28~S2Qi%s#Ln=T(Y#S28#xN+Ytu?MCb`#K&ShafM588Gvs9ZD3yU{@xQ=?jJ z0?djOmJ#Fi14l>NQ3h4dF;sJi2Ui55T}X#qR6T5hv~H+fcZs66xEJ&-yZ)gB#UjzA zx5$h{a+QfhwTi{sSJjE%<9ZoXkBS|G`SPkFu6Z0TsD^ibcX;8oaXuI;M4Yq&c>fI{kTkL-Dl#yB1O1xq6yq*2&gUQ(Gq`$oOr7|dE%^hO31;R+fBaYRBoOfqZw{+vUg=hKY4m5%>KnFcJg9#2Ge-^f z7{&rm8==xehbL;Ppj2IJFLt;?)7&vl@k+mC`WTuzdRh+SjbCm~iH8<_iYm^2>OH-Tiu)mJGCE4U89 zN&KFPFnkt^qU?qE?dxJ=pHjTiu-$;!Oga1(Geqe7JnHg%3!)! z?^%idVRM&YHlF>B$ns3aKWhsHjZECoX%12&Dj;8S?)SNEU}&1LJ-*FJ8^|_Uo;GGY zhCCQ_?iK%j(y*sF{?Gv4nHzO@K{*&_?3)FOt<)Pc_bSG*ASKBzlxrTE@=4)&HB4QP zqN*-}F3@KxogH$b2hwx5&|j|XOeSenE!KZ&&iqCXE0VWL3PC{Mw&Ihox~IuxR`UhgMHCk3qQH73;En|z*!tNM z-hp-;F^lJ|_L0%^pVT0Csy%o9I~<}1`}gGZU)1;~kQh7A|1&r!N|v@==ldH*lHYMF zxm5r+kOT~dgvkBLLQCZZY`d(MA-9J=^0tDZe|Q?c0~X1l$3QeM(oDBqhYhD`v)$pX zN?D{LkcPg%L6qRq=<6irYKA&pBLEHSM(yxHq~}sFoao=)k2Z-VD}Y8(NbDmD$y!v1 z_07a&*jc=%!%{?OtaHS0#*Y%o08HE zE#DPt1Y({RG@ksbYmH+o^;{q?PYe4-k*b=+Ecm-r;$+1s_@9c#X0cIi7poX*h}0b^ ztkmfj8v&pd{$!SAMaRzf8f=TLV=>h22_v2;aa}H2QummGs$y`W4Z*DTbnro;t}nDm!+PdeS#m)PmE!9M9OXWfMr){OfHyovaUQ-UENQ)$h_b*eivHAP3XFc z>{$Rdn{>1mEu9h}6ES2~>Pkt)nGRTq-cq)twtMVE3hE*Tuz`JL9Lbr%US5hquj{`EeWfmGi^&Stje6>6?11xNW+qh|K!ZU5xgRiPu!!1?n8rFtKW&M^MJJB7w;1^63t?|9Z ze^Wr&O-dvoD5M^t`Hckmv;G{4t0k<-F#P+umj_w1^k_uEM6)OXTn{ZgRy{LV(!0C%HuLRn=S=@ao6K(v5Am@AF9k?e5M__Q$vyu3kn z$Znjc#=g+b+qt$J4g%A2ESlH8VxKF zAF+SClUx_v%`S|@Zr8CoeZUQ)u@9(|!&M9ZV#pIexOXR#sn!cLd`fdLO|>ak7sUii zVtxoSO-gbNBM@4>YMrE^d@aKOC%^Bo7E>quz#?2G^mkRAlXIjtRBpsQ*VYxBVc-Fd1#5Z0QA3p0#k6qc@O-VIxt zNJ5JSj4aQj|0O<3o9IIk=TB>OoZK0>;<2+JRnWvSO6VJgaF0JT7&V;cWj9{@aQjw zNqxs%`;?^df7)V#Qt5w%if~^(R^2)!VAay`I;l{c`=v#O@;T7!R3jhQ>BX_PZ>wPy zLcF9~7s7Tr0**g(5k={-giC{k4|~ZGi{sYdo5LV@n56dSpXu2Px8_o#p%(gheI=Cdg|k4tjbRMQ+At9WQLR3XcSpkg&3*Oodd+G}%iGk18EW#!GI z{5EV`BXTlvf<>f&3l|Mc$(t2fdbg+Z{-wxi}~!ypnexRy_7 z^kV+Xhhkd7$Zu0-;e*>&caV-tc8$hD+M;2=iMGR=s>dXxSa>5Q^QuOk?pXia*E1d? zk-PT6!f_Ym*mH%Ws?#XQM?gdPRpUnC&LRj%OALvxS=m!G%#^78P?f7A_{Ns69%X_( zg!F(_UDTOdP!oZLrrN+RC=i`AiLpng0KF_#z9b!49q*{N;VjFlkj5zFILAn>EU&## zWuOw|j;S_&G?d=iXOV4SmTD3@Q{<`ckl|RRxlW3t4Tzv~I5X!wGYbc05vhm5JmjVv zl58>~aJx>I+R5P}*(~45=zB;ca%R2XIwJ!i7!Vfowfq<*ObWj3jmLnRHMV`xZw}1IYPAy>5A)mNGHO1%LyNTf9E64HG&Z-fmmbIcQma&-uBS z`0gguHL4fv3IrO79mkavjWq&o2lMm*lfmVEPyNYyC-Zt7m80RFO}xg$8pYy$S2j64 z{o%Ot{l@)y_^iwByLS9a^WTJrqNj7Z{9L;}5S=-6Fpc%TACr4Ylk)XAJ^0?fzTBPN zE!!N~3jE4}%H~+w$a*{c)N4LL6iy(1URk@;b2Tvo??IJ?^@_*guz+3pGPW*wGFmvf zqk}H>p7$onnlkR*#1^dfg!@VyCEdICPXYz)hd^%gB5S(Ni;^`{F^*iozyNQcz!bN& zDujq+#K06M&rvL`i(kBt?~jgF5|Q~j0_JgJVFbA6W&U#|dlJHfGnxHe$eZ=`xw#h0 zcWb)Pwq3p5y}hZLD>G(S>Tvry=eXu?_>BHG%1!t6=MfZwy&_+wnOTyt3`s1FkeBxvd}(@t8^GdEW|$T*$Uy=n=s`}o5ClEq88B38V#DiDBsyYJ+yRy@hv+b62LaF4#jSnd<6E!FoJ5{PNo<8f?YA1;Vf`JBJIB zrUy6s>*I2_t>M~f`uW6^-S{baBI(tY-}FQj;86d8xh2#2T;hSxjETnN3(Hp&JR%bYPzm zh{ML)tNINesO8F9(PNnw=;grQ4Py7+JE0k|xXMf}jNY#g?CIL?S~j<*q>qNaSHr!! zg?}4iS0R-%MsG`fj_mDk4Yco>w#47UDYEn#7+9mwdmW5jKy3%2jAwaTPNl5UiBho6 zF99-@BewS&D`)*_r@e7E0gf#%#Wt0rK0(qIkw2_FgWuTi8H~_WgWU)vIi`p!OzqEO zZ-!P5uPZ*W+JS*Nh%G#6fy;gwo_F2G&jg_JUv9sDzCQZY+CLmU^&c5He4S5Xk7WKp zZVW*>E}u8`Z+`8MgE(4Mx!IHVv{_Tf+-znui)LpqT0U|(d=}OIWK)xJljHmzD)_A) zr}H2ln|0tPx3C}bc4}g84zXyY(-XJjX0+9=qozSa!#iHXTjn35Bp8XA5PuH9G5(y* zlh`oh7>Tw-W2u0K3Hb57L1VeUGOn&Dc~+23j<7;zwZ&w*{n_q3((<+&Q)LwYB=dez zGqpzV=#n7&edGHwm1QKr4y0t@panZI27|X*&0o4LRofI{zWCtdDr_N|$ftizdU4Eg z+AI#pfJBd=L4gkcC|~RVl2CKBV(a*Rdlxc&VmSd@RmXaKskC=yA?-y?B&q<it#_pp8$=I~fg%FAh3&Gn-l6iK8fq!KhgiTVJbrz@BjFNLxlz!Z`-fcJ@Dt)$j92_#(pv$SKsnpmugSv?I}F#E%kU2`V)f0ibht0 zRcBu`i(LB=o&iOxk0E)lR+^;$cV6GpK4mqD$?4X*POggwO(&Cej4bj_{NZ|_NR=k# z@}b#pG{=uPsfR?6sD!wpKckNk7g8 zdY4Kk>k#g=Etj)*)AfT~jGnyk8MOo5DZS8T6L=D27`0pL#%Ti^TjTguH&EIx$ zE;4n+Ej_x^$6Ii;sSi4^7zGd0T?_8mk2jwzPJf^^qc+>LR*8;0Pv#jNFUM;%<#H)2 zcHzPcA!4}(vb0Ke(7VMt zrFlx_sLI}gs`L%A@gAdYehs|yh;TR91$-P$nF%_XW_i3$jHPaWk1^tU<$SLNRJ!RD z@T3Hzl9$y6BO4p7w`aB3&JH0uXA6M2(gi9{4W?FMN5mN^mtjX_T`MtCtLeHR36sPA ztgY|SxOx6`&A{k|Y+U1g9p}8=omrB6mOD9l~=`{M&!wi%o=W_-BX&1)kcf7moe2{s3dDI`h zaT%h25)yoG50SyhMPs4kEvMG{+sk$@6zpn|Tf+F|&CuH%RMph%aYCMKjKkd+z`EZ=1m0oPsS?dNa6u;$I|^l4iwfCL$T zdf+aUW=O}HNz{f`++N~~6U^IrVDq(}I(g8E0W32=HkQa2;RNG{jeP_zLd-`5-N^^~sizehOn}K8Mj$a_ ztr1!~536ljJ@{*PfW7(N>0-~7-j@|`<_^DNZ*La zqBPtIdSJ;0^$u)BLxx;wCD>^k{wbTG04O*2ZmIa`jI8!zlsSC@>eZB{c<4Jshw<4cL>1TiREzu^44WWWNp)PbQKb+bU?o~_=UhUcnW9@-tW zy&Jh-N0wXK-(`-w&nI^2ixbxNguQ-rTb;54j;_Nx()?cE6;Whfc4i-p(>-bLyrIU* zN{M@Ytl+|_%~Nm}PY1fh3@^t%^?hT1ObufLWh8ZcRd-C!YT}B+@7T;|C3%-DO?mf@-HK~& zV$F|vN9(%E>#Lq^b~V)n=)q0dTsO8@b&lLmvQ*IhW;U~+eohn4ZW_9{g``XFY`x+V zr0Jvb(V9D^T-V~1T+(3oxY8!x@6DO1F=9vR;9mHMdK!O_1~>QzFA#uFs~$=hiqmXF z*tb}kTs9^dD~04P@-8#ssLH@jOW!o>Vu@S&#wgEnRk6& zaWT<`lo`lf>LB07O5y`~X9wg6*-tQuYF_;ENYwPgVS>2DeI42m=9L8_UX_*`c*gX= zib1EJ9)zEtla0L&I%Kzq>A1jrX1sI#wVd;)-TXS6b}>6J#?|R=Z21h2_+4_&qOSto z2f{*r28@GpVkyGyxH1!z`b59lB}>SE{W>m?noPZ)EANzAI&3u%V&h-u`1p2910qB2 zgo02b#W01lByDA%>?*GZUxem>v=Y{-^nBJOdTK5*IE%RV2hKy_6rR?6vTisCC|c)`%r2-*jqCy0x1Tayd#9^WS^;Scuo+0v@@dcevCf-YSvgj8 z2-3A!gEAYwejt2+lGn$^8eq-+**lxu$V7S?NOgBDyGfTD_*W7BNRy42J~J{(jAiK3 zI-T`it4-p8MtozCexVn8f4}_|hJbScK9Iq{MjCJAOZ@x+BB7RauP=6xBBGtlDQ6K8 z@uIHjId^P0BrzeH1gJq<64P`&nYIxY!YKU8kn;+15GCC&d&j+d%TbYsIoIXrr4XV* z1Xd+sMQHy>gx1oSUg0qhvT_S@|454jM>|yk4&wGskSQk#i8{^WFLp@c~7|JWM zaHXnUt#I2|^IEl}Xr&?s(3h#Yj-e9NspR@xyCREzD#Lrc!)m0Jga|9}wvAtY6(Zqf zL>7|_4c>>!0lfkkuJ&%g=KC-_mJmKB*Xc)2V_!{JrNT#HEV_b81xnn=)lPa^&=A*h zlVKDQaGL^lu0gvnsSz9^>lpBBV!}q+Vw(8YoC53P9!g7w2&7`aKtJSq^{S`rc6E%F z#6kr2jQT64f73d69$IpjiUe@xjUXv=(o$}k@pKyLpzjs6WEmJ$#;-vqVRj5u{N!UM zKkaKeT2F0ah&U1e;?T>f3yPFikLx?_9|L&!=ebN6g}+*6?AMf6lE#k>|5gZ_f|apy z@H_N}lvR&6kehkl(9!87+W;sa%DL$px{t`Uf!4a<8TWw|q(~{*XIUDkAXcagD-TOR z2J9TPqqnA2ho6rYqsRllRag*gP(N0z<#r{1l)x5HdzPYol5#E|mCt42Ufco=G1G*g zE}cb^pim$vyZe*kC;EWqn&H*{w4S-Ggq-8Ev?g|jNLCYlYaM4YIYYQ^tbcBl&1kvB zT?oB)7J@G>PEKMsFR*k9r5tD{rc}6i;f>UH*b9<)flN?(Ew7f{(z|~Brb&k*2CN=Q zGGHO)*2)P`qfDE3>%&=8Up-VVQlM`u)HD@e)<0!w_8ZD*b^1y)_|Dn!GIW1j@?x2Z z<1QDLq`HF!{Pn(SrEL*Yum1;|fn5jo+_-Gj@By?1Zp^@0W%kvPsRK1%TnLW3`s~ z=v!=6uTgrSlEUmBqLRX(Vn-a>03ZUkSEkQ03i!AZtGLHiX z{fULFH+ZAtZo7oGNG8;s1lM#{ zA_89W4BmH)Our6nz~IFa*RXc}0TXlioZ0azfZd_2^|A8S49E`{7uq>@-u_!n*JGDy z-?0+Y%Vf8pC-X7iVmFCnF3yV8K!hy6tiJ)zFD>iWBvZcxTj7S7emvh>9+8}bdrT`4 zo*9ph7u-EpVY%5&D#BW#4!i(uJ9Lrvc~h%p(#fHj9t8nu8R1B*n>?G&)gA7`wr!_Q zl}08qS$ajTnoU7*6MVlGVvI1Z}8sFUo#>`S#&@4jo?rS*)>!DCk0}B2{P^D!{WuPQ&CLI9}UW^?f zt_Q1w{sV^tfF{w!VkwHOh_k%??m&Wbdv)<*DH1BaqWp9d;dtA51iZ9Z84{t`o(goi zW|O>A!^)8H2~r&tZ0W-i@|*VJLpQ@b6i_m65)v&VNv)>!kPWj0@2Hd@dOZrpx^ERK&%F~J% zZ~e6nVEcMzn|X{jxfR!7;PL3i=kZkkBL#7JgO)RAowYSvPjmR;V~9a+J4rA!`>bF(#s*rl&s=GIBZ&tPDw4vi#=zYjh1+t$;D2mo_d?nYXIuQo7<`z?k+Ph3;J-y)Sn*KB19;lZQtdtMD{9{YR&?l1tC5edHGS>_fQvp$Nde;nQBC~vwD-g5?A4r6t6R_@^7+{TV=9otsSHDnk^vpSTWHc9K zt4t-2yTU&^j4f@lBrF-8dm&pVu8bAz>kCaq;$Trh1NTTr}pMon2{PnQAQ@pBX$8lXs|n{dHvIytoZwY%aevZVMjo z*TKVAYmj*W%HlnrayQfI2s1>kKk zF{#52o}#}P5=@OCGxhw)a<$=XDTyfH^<)kEv!5J4&NZAe{U}SU)t&%elAk{P71DnT z7b4mL4a<$AN@y{T8jBnc=5&Oev7?}DP!WRN?642iIwdmCl1!z@BMW=CS z^1G)`vw7!NhCIDm0>AnLlAj9L{sDykJOn+6WWw7SMVro}TPo4?`?GFg zZ96;BbOA`l8#`DRhhyPD>+E`>umLKRWtBDT8|}=ojsM-Pyrt&mbx!Ed_XsFY;@M2$ z>d@cX^Q5F9@C4MuXor<_rqJ{DAUq5k*=}4sRj8prUOZBw8w2@65zy%4`ct8!xIHXc)QN%tx_mQvVH+KmSU3;sxueTtUE+ML8%cf(Vdt0?uKdy==ke4F>L4A@sa}uplGlsq zHzz>an5h9Fv<6u>4M33M(%8hDOKeXmH z{nyveZ>}JLTCg;oe);ufclAUj2=WX77R`T2Fb0r%w%tb;K?>4m<@<7KiHZY(R9s`( z`}`@2@oGdxZwe7XbDaOy(hv+W*wQb$s>B6k`S+-ZNxhOtrq2A*=4ENGwm?F@neAwS zb?-wJt%<%yw0x{7dby#C=xA;FHUHv)Zu@eoHedv#NWe2=gpaT#$gV{Vm{_$=qd|lU zT4+5NtAgFHL*1`4v%R|ea(ujvYjI=>;?=dDzRH6zV~D)~T|eUH690TO%Km)q3b<(1 zDFz0@<&a9e{&yR>L~1*W>zI;>g%^gTOT{FVOIj@+z=o7CNKbwcnG42`#l?SBh=-f6 z)W`#^3_hu^u9iFSeg$=@2yO{ZXmr=GBVArVrB&l^-~Q0h7*v<*u|_aji2-}*u?1om zk)YyFVcyI;T1g*1Lw`4;Xtc}*Uu_{mVY9}37hD3bCO!D%;@PY$BUsbADJq5S z7SnxnOG(Jp(&G4%&)gM$CtF<8T^11-krMN>^8xtmus;}oKuH)m^dZEA@N!%R=Bh}y zC9ULRXH;SLh|!Q~k8s66^nDOxQ>EYFBBAc21ZLO5-{gHL(uPL~P3(WdJCpOV2p8j)Ug5!i}oAwdk+2Jk{sHIdn~ z7h-!0)gjb{xe)~BgQSg_JgyR;K|zZW9Y*65Tc6s zC=7A+6DTP$C4Bi!YI$XDqy**c61GcdX|76iafBo#{)DV7iFYqvR-A-F*om|5W7?0j`~2w&+}c_ z)BEk;>t^7B%JG94h^Z)9%62O!jw|mK?Beld2+4)j<05duO64D(+g%B2aTkjR99wR0 zY;wmwU0d5O9W8k&g&6|afW#M%=HOZ)o=pKBNT?i9(JKy0M(o8v=_FSa1x`iq5?Sca zFi=m=M&El&nAjKCY}={%5kcGE7e|7=oIXm0m&rIkvm-d9m~EA`ENR!{MxkWU@VN)+ zJw3c7>4OBUU~J3vk4(y+nhK`fw2l!Dg!k1{_YUVf5D`hrX_L^;s=hs6f1?INNJ2H2 zGGxVmMv8w%BUB~ueUlG-FKOp>Ano~cE%qGm{0uVXUDxE~N{Fi7r1k{KD?1JZo6St4 ztoMaM!5f-;Ke5$AB;^tk1RRtRxCI=N!$fG5wCN{!zsWN{6TsIdsLFXdl_QIyl&BD% zJ&s|bh?^xGunnFIeX1P5PY_k8TMRMXd-JXkb!KyIzd`Y>kdVL>R2ZL*n82BnB<5(t_8PmYFTDf3x}ayrB6NN;V)gxaT< zwSWEV&V}C(RLsr~o#uP5cz6UX#^?v<%$qr1eVP#TRGf=N{dtBUFl+2hM9dxk)l46$sgw!XwRppfv>5~mqZgs%&*2S`#j1H$q!Lp zofAi~ueDhz1uoAQ8<-owx*`vXgABqov$zsF1NCN1IAxCEt zPx53Qcc&%q)%iOzw&sh|)UqKk;_#gS*^?$>2-Tzp6Gg=lWcVl{!*najMN~3HN4%Ut zkCq7jy%w$ybr`JZ5GeTlezF@EE|j^)@;bVj&T(HCez|iKXo?^>fUwYk6{(z5G)M*P zj8FosFp--`DeLFqN?xDpGJ)W4#(I*RFDKwYfz&M71W}uHH_@yagF#MMKxTnBdCVy# zpcd2Lfb{~wz$6|+L9VTAsISki5CO2o$G_0k7PKYGM}f4m+y;+Y2Bt6I5(17ck(ZaB z503aSNRc`#JvI=V`8ziPwPFb>u;mw?DM;dN52$V^>7IW#NH(c473bHNjnU+<`He+d zdFm9A6d!;y&kI2X)&1K5_^f3;80=)U%MS&f95~T-H10+QPq9#dEk&9>RcA}m9}wY6 zp!*I;Vg?+D=Vv^5cCLLEs>V`7#^0pmjYUe}L{_R;_>79Hv1~v(yh{%ZA#7D7hk!F) z8dr(&THifgt2b>-&zE|RoSH{mcAJhr6zvLf7!oD?8U^`Do5^Os=lzKNGaB&&r+CfF zlJs#Q_ES&0;5{0fV|;_~fw^fJ;JcFvjUxNvYV5fvYUU<;A$fkRfuOhZ=;1XV;vlc`vck2oytOV2b-%Pj6FnH%2NNCwxN4doYyLe<_Qv z=&Y`+I6t_nPsT@85nrxw1Un#EPS#bWI(f2e>I^~4j4m;zzg;R)>iO+qL-t}CaqKmb zQO(#goCbK&0aY~necQil>RAric?-EFTn1riDt{FoaA91S^FP!8Od>dfUf~qQd$l55bH_OpK64Ejg|NTI&@XNeZGZ1PTdd&VAYI~><}Hj5g5 z%!{LJ_lWswI2}iFJ122CR)s0AwY*FGn-@Y}a72;0Vs#ojKcCBtp{WLYjh{uJvjbu5 zz+NsN2U|3)G8vjwxvzM$CY_9BMqSaTeJ^LJvN@0*)^z^HRWy-=nu0k8`c-1vxFecBC)vU0oBo42iotb0Ku3BI`z1968>;4G zgEW9D9_#K4(u4zG#m0abMueeSvtVrJ>sWQ9jC@gdbK`U!zPnaA?Jd35e(-#fdCj=hT1!$MEopy=>A_^_$wu(NGqYRiOfK(_{Y(0ezthhi z-$`X`C+G+74NjlJy$GGT<57FN%Xs?V+;;Y^;9Xj(Swkxx_(bNsA;5xMzM47X)8RbY zVs;nI-89@y^5kGV*bKPds_jK=N@?8`T$1?QI1&W?Y^xHS#sj)f?zIS|Ucw z@SlU)s74oyz*$w~pzWMbT3C(AL;CA;LC^bQe!MhjCf?@mt;}OO%OA!`jFZ}IS0>>} zD-Yk2m@IHITqT2Fgi3Fp{n(lsx8z2KAnOn)IL3~Dx@*S_tbw-_;8OY5feK!FsIVuYaDaXX zYSc|K+wH?BF!F_SNxlk0gQA4gdna9x+yZmGkEJ{>S}IwKhPaNx(GZ6DlpSd-c?3_e z^~B4^8W0ToR@H{p0ME&9;=v$$suYp$pHk8h@bx`kTSV>7o=FH0#wgp$K^?oLp#Rmb zMwe_q+$qP6P+8H&*v=-#0vMC<;(&wN62H>~@wzjk$9za9DWOO4{!vqlY6d*36LCC3 z{sZ^KH_HGZ&`5Q+iEJnZ=ouR!Wf+mCwuS(E-G2HXLRKB&etET zel0b!80llyld1U8;M^S)IXV-oWvy)fx5R;AXFTMj&u1mUKM2GKGQ>|vx2z<9Lb zUSF(LLQoQ4&-f@axSY{DG^Dd7pm)%hw*_}vclnGqqp3GNQXG=94Hw~~u_d#|Am@kW zJ3RkdC4$a=xw^XEP-*;U z>Ed0Gy>b*{8CJ8lAkokx_bjwJNWX(3d8gKIm^o|M->x)rCn2wo{FX%(9Mwkf0T}Rc z==h$0=y*H7z~SGx%qV{w{&{kFp%oQmh}Z>gA(mkL7cg~%;*lim>O=*j z?E@dl(jf^dZuoZC;M={j}n`Q|D~CT%{W#cZp^ z|0U_@Qbp%EoE0+c+u8OBwoC(VIE@87oD6xghQVKX`ilx}`f=s1Tc<=*%ee?k>RCQ# z_lFM_Nl&Jj=bGG~{=5*RC1P?u(*Ce}t#&a4+&@}6DoHO2{%h-{bIq0Dq1oMR=QikR zQ(p7x7hQ7?8{Z8mg;qi4ADM4-^{UvA%|m6fyO`>v4>?Ya>f3fsuBOMTso`$My0&|^C}Y);pL@fU_4;d1 zKxXG7k3`_CkIHwlI#utbba!zVwOoxnm5nv8xBFTfhNN+8yLf%4ybqOgTzAZq|A_ft zUpSHu@@^1kcf6OOlgcBa+uZl_P=O4VWmw!9ztT`f9As#8%BGwRhtyDz^^GjL-3|Iq zkn|!AMH-kBjOQb`tLDpOnowL)MR9E#nl_4JlojYyl`OdtAlNj|EM%jP8LKhMaz4lS z!fb%A4-W}(NBr5*_?UX}Ac@#^R?@(iC34-ZRe3A1M+-_!<=-M0kF! zcj+kFC7hlhxaa|+Zj_py*I(=U!SW6IS6175!a|ekh~QA_YBT%&bt{(sjw?zLy!F-y zN(-wj4TA~x1r~7F2EtIZQ6#i1X_rTV>+R$gqy@2Hbgresl|pD&-+k{3G`sdIm{#si za$ajKIKr&R_KR>1sC3s>32v4?+Z4T@n*+`~6h!89!h&KD!Sx8FhzWqoq{`#uorjev zKB*!GU)yBveV>lMn{l-N4!VTYAV{2r7tv76g5p)Emtc8@C1YN$OlW;y2Td{WMGS2? ziC0c4XhBO3Y@n|2JXW%xn3|W6awaes>|3b$z&JdV=4QaOi1;_KHdy=0Y}DCh@?}-=bm2yreRF0s<=p8jU{n@MnDR2 z2rj;fo}dytia76v6sEwbq2@qPn1h``kxdjlg4tYIb3vtG`3pj$3*9X&UH4|}SD3r7WJ~)- zltf!Fp@)kg=}JuHSH;U$6sKOv18@=ckSAcm{*7>@>0%Tufncr02VV`jx8Z8A(LSA1 zwJ@XmNucZEiMx`xUtkHHK+%mw+KmRZjr;~#2B`iB8%EG$_^xE2{b?$p`_6WLK`4ve1*l??I5?ghLwxMFe!F%BCVM$-(e{!t z-XO{WDNO$6KTH0;G_=k@VLqsUUPIWYhsUw>ajWWSqH2PLAXOR+O@lL6x0#R5!&AAO zCkngv2)>=U%l?fG#^lWVc*<(m8zwMr-v2h|u3epMftFl9HSDY>E$scmWfx8fdIt2F zyRHD0cJsib1uWc(4?4KwAn%Z%?FsD)u3fnbn$$WR(N9 ze$N*cojTmD_{@u7`StBtN8>93(@yT)d;zS`;5xY(<->&Iaanu#NSoLvsh?ENx1aQ_ zX5zNHs7W`YUCLAH!#y8M)Jp^nKb*7WY9xMagpHM#Eqwh@=lVvj6QR6x3t>SUPhif_ zFg`oEGd?~HsWS=zcC=YFRL!n<+p!VC|92XdZkfe)G-{F{wuvAPyf6~bcZ^oMvO}~O zO2)oH2(DI=2M?Q3$q#dkG}WOU7E}~!4ntUh5Kio5vL;S=6T)a()(mY$N({&{BM3W9sQPy86!P0OQ!Mx69%}Bt5qh{M zF6v-*vVZea`=ek+iAci8{-`+jqp)vNMgy-d=sls2nh|Gv-t@>3=gyC1UCx zO)?s36rHHU4(F$2=5K4x)Qz){{XKvKRj)5bmHz2BZmI7H60dUJvAAO;3apc2JYf`J zQDgb5;2;ut?E}yQF=?6a>!KSCn1^4$d+xs|W5&pMH?J$>vG~`_d1ugKnirsWGvnZ@ z$l1MQ5fyB5RB?H~z_x>j5eb(fzzBKUua<>i4w7Iu=FJ=iud$owUqFFnhtXNjn7>^K zwuh+(GjR`xD3G9Kh#_=5uyD*7e(j@*Pz47=Yz-9!OelsesvOo=kKG${T+~+hipgZH ze0OIFA6Y%pym$xoItRfgts*-C&ql;=JeRRxf(L^E{6#DmD)=c4z7^Tt-x9U$*`-t# z!-An`gn4H|WpAUlkwjK7bcf-{LKOb(=xlMlV8;a>{4cIz^$p_Ud)Amo5LY`QClho5 z2rKPeB)<~3m2MNFpTO2Q)(=gu_a>(I0rvG3=e&T|hlg-xkg)T~#v6h~ehWs+LdyzD zZ>>q4JO^N<_5Oy{XK)(`)PaT@rWm@Aq@bl$OV;3?=BAb5F!@wDCSN;2=3LhUV}#b- zZZNg!^i)Blp1$Lh$9r{<*%`~&y039CJ*o7%%(ttE;nw#VLl2=G5Ds)PXTYP zy0%Eq7yYejV3!LXfVyd0MW96#xALKasUaeR*x@o(;YULelp&Ea!R4kYKc1HeS439+# z6APd&+}`Md`O=^-=~?w5AXPUJ>7Hd}Lvt$DWeF|vns$0zKp@~sN zsXt;Q@m@no(bvG$jRAbyWI|hRS1yul$7HhVhtdPE4{6pz%SZeG?8ox5tgb}qt{eCS zSHfBD~Lh_w|KG1d8PK?Q*UTAI&;?0wYLK?i~GA#Zbcfq@nYWHRTf zYx7!`X5#l6ChoqJGiPx{ChiEdeI(gv_?yQW3N*P&CTmedCv{0N4WbPULVl-8vCeV& zuqfx-MI%1v0gTMN4>~Yv9;|3U*U3e~jrIry{YM_hnXK~CWiEXF=d#}}FoO^T83%5` zwv-^t^0%5|4oYN+2%KbS+%^kn_#q^Tmteu$m#=EmtV zC6HNL)fBr2NtSwwa;J#=loVNWr#i;wQU0n~eZt_ucs8k>6acTF!IE+qOxeyA?dXxqzxE%} zO^VW$p$quHvcMJQm8+{7{`t*ngf%b9pcSAZ%@I7kHq!Z?gz+B99ly8Aa;~tDy9`N? z5DS0KUwN{g67-D|OO&AH2Jp#IBq79)t>MnfK(J@bCg|aU+i~5)&31y9G~du{{$`Ci z%venKmJH}rs=A2|WzScxY;7jIFPc5*%W#uTaS!6{AS}7TVGI89%af_)UuiYNt;}zt zZAhV*TWvhg`#TA|J1G!WU^s9Q8GHdAdC1|d)*;YOKw0PnT^_Cf^E-5s3&RFDbtoBa zU)W9+H$5Lups*_S_;`=OmHz+V@NPDFp{PK1J?!oz+d#JB1O|^;TCT)~Ix59s`5Vb{ zRzTLiBVCH>R(o2!KmC1&&X@7w)&>_(sT+9C+ZtWA)ZcPzT{>zxwR1=jD+d8k750mA zViM!{+C@-cHXd?YO*}_Q7&0XV;Lfo)wfZwI+91oDFT%Nle%7Z9zhj*7M94J%HYnii zJO+PMOJw74=mm&p@_h>mubc6K0o$3vuFx&Rt ztzj~9sQ2v6+0s!ApNPgBt#4QPh3719ICm2!Ka|7j(P%a zvI6A<<&l4MD7a)zNxGixy<65op*Mow@i|ntbtJNO2T9a71Sh-)wMb&d_f(#EbYnX! zn|@nl=COwQn474kV2_g;8)uyFN9ZcCQ~#_|jB^MHSK-;yS93vbyvv@r*9BC$6tYqE zY$o&aWJOBf9Q_)#8(GgfV9m>rd(8j{#_$#!(_o*qyStYfA6>ycmsgY>qg!a#TC7pP z$DRR6-mN5U%pLBv&*hXWPardZAh82AY`Owo21^Vyi<(Je&oA+?0!b1rBlQYq zN}h75qNGCEZ1OC*`(&l=&1s;A!R7IbVAW;q4{6<>`i@IYjNFM@=+o66+L?iVS5O9Y zz*cV|5=Hdu}nE~&4^czTJ*&zXirP1r4xTnB7UI2W}X_)(TW6=E}xx_IRDYPz> zT04U~j-Bl4{AA?uhZY}Z)Z90-lHn3BLeb_aM)?9GmlK2ump?RtEj@1@T!O}ltgAE` zk-x76Wyd4>_l_24{N_J@CsO#_mXR~KwpizI-8rZU26S#?*}ZqMI-z%U^!JOIztE`7 zTtjYu-)aNViMp7iqi`5AI&a~znn8;FA>6Usx>?pe_p%?xaYV zCl=?#yM^j~Y0DdD{e6pI#%+V-4xBx1b)f!<@T_((~*^GI7Dt5s~JU z+Qs^q>8Kl6y^qKILqfat(4Dpn?2p-BMX8D?mC%vMz z1~5Te<^p&2(>d_3%=P`}0Qy-i5w&Xg{&Pg+m)^;tw84Ug#7wLu4~z&Vsndz-oU+;$ z-~mDROVHc}KGALklp-V$^0qqiVn(AP=SZXK*0egFJYi`67)(!P94h`gwmoHf*QHUs z*|GoS$DwVK`=zZxap;=Ns-{(#mBEph0BL|Ag>W5Co)F?nJx>n3TIRthMFK=S z9^)PN2m%6b{u?491CU2-gV}`FPocM+>!^VMeUL}RF1cq{b}y}WV9i14kg;-*fbs)` zLk6DLit0h4WrfZ@`|uX_Mp|w`zke}CjeC0rQ54Eaatl}pK#&HBih&!j;#(Sr05N&# z?B4HhJ8VoXN|K#^V8;CmJ|;eD8^pJY;rjgn#8aaAYIQ$BFoDen@o<|07tYw5eq=82 z)?C>o6V)mPHFQQh7}mr?JIcNJCIAI%t=b|g?Md2^y1O-HySoQ{6A9~EXJeD8iVFZW z?U)!b2@A<9rFZwa(ZtCHJD#w2k}hUtG!-$cAG!gopzY1Wz=yc<+M4IHS1muq+e;ta zJ6$Q6bkcMOfO-vnM@HA#*2oqm>SDt?fR}xPSp6q|kQMT$*{5gze6nAhy#Krn-b02y z`rNc$NC>vFVQ!Xeih2XWWV1RqxJZtx3bSh{q+0&9aYBtW@NsmDVJlm)RkH|!nHjPe z>*3&V9yGr&m}y;1D6!gHiEOO6{3u|G0rUF6U*cZJ13*WvwV>hm-!Al$B~h@Mb%tPN z8p+dAmrwt4Q5ypAP%(Q%g#ap+x+i`wy`l8>D6$PtULm~iko@k*)HuB5fx8dpGEk|D z4dI99#?5@!9e`NPL8tQ6w3g0R``tiRdMP^_5X!AIvaYts02@i*{UfGooc4I~sBBep z{O8G&Y@5f+_{>?7v*YsCmLG|krB|Dy=frmatWk{>vM9PyV7320j%Ud#FoJGW+iBvO zb%E;X+Uexdo=jCA5S#{nVM3E3L6>5|EYOp17M`+yApwxj`l_}>NS zRbdhe&>)8UbrTACSRzT&$C|Z+V)ZV`?Zy>zjr6@z+Cz6a^sUXA1VQ{#`-k8XEu_&` zLxkw#YV|Olf_q(TiS_Q3&Y@}3#(Yq<;#!yF^TZvBX%VzKQU*n32NHa~v3%51UPL`e znZG4McC%r^tbH?Y>-oOr-p24)QO=xhXVOv!=>!#etY_ssOO1z^;sHJ;Hhve=faIbL zZ8T0olpR5bXneoNAU{5)&)!I^OY~*URCzxYm01!q(Z}!E9F)rQuSGGlmIBw!HuC}X zD_mwAR?r#Ie&w;w3H6!Ct1#okUN~MsDzr(qEzV&*4Ws<_xYI3_M{iEuCG`+jI0`Ew z@%l=Fy*dgTQ&S`~+yRJXX1@uf$)5-;?M1G&i+VPBYnI-z4FpuIqNRxqydMlhOK>(H z+VT-YpMD`z3I@UClq{3DL-b-KqtM^(K8kL+K1d{+VvgVS`pS$s3sRZgukfdCUi(qK zy_$c$YCW@40U}LqPQy3vnB~6yburi5LpPwJ9B$>5Zla3rq1QIq{+&-eqMRr-JuJ0d zfRJQ8USLgmU-tu+zhXIHZ+Mkgk*EJT_T-8D@jpS#uw)-KjbE%?iv0SjBE=*!H-4cn znyslSJO~k49MZx`6--7M==E8@u%b)4qk>AGRd@>A!< zt9~E zupKUcv6YfKq{|B~7}jdNo9y=bu-&{9aM}^~^Rn#D7pvSmD1yq^>hsbQG-T%%XMiXJ zOQ`>lYB|mbOy0G$t$NXwY1H9k(Iii%r^a@VU_(!>BPge^-HlYXp5RIxD?BpV|F$C2*vfTKZr-$hH4(KdNKVEZDKh7TyJU6^_$7eVqgW>-&Iz+LwI7VK+&iqmw zX?=1v;tCVp?)HKZRE z;#OlC_9se?vf+}LOs0L+^q<&CsJWVX4X%@>rNm0dz!JI#uW{J=OicjZcyV?+esu2M zUm-ghFTUR&*kt~G^hvD3dMci4f56y%E8`nP6b?^DlSmF0fOLeHG1IqmuNx8eCI*D- z7?enn-F`mPkmJp^4*dh6;O6nI)wR0ZSYUwTI_iwNaXRRZ(UjC(?hgjHbB~DtYPxV= zy$7oa?6{~xqV03AFcLix!NXPesy0BaKt?Z}^z$v%=%>7DF+e_j+=v*+gpb}!5s=jOLLX@rcZESrNcl2VSNQ$ni z0U4`w?-8xHX{aLiIFXc;dR(tS<3J~crJSfI)z#=-LO0oO+whucSNns`N`N-G4nUPd zH395T;5!+XVeYoaEos>a_XqKrIen~Y>iVvCT%?f)leYVABZqWi^@3dO0X^9rwmk3i zU7QyLfYtY|E5KB{kv-l8hywGmjiNJ?8}*InR_}{Bhoa2rJJU&2O>VHyA8}^)xBJpD zQ1;Qhw&q9*zUuf$fSC-2DU_PSu6OQo46w|#8ku$ixXo@0PfA+xM%?c@p7(Mi3l>ff zZ*wcw3Fg0IfXm}yG|Q-sMaCwFQt@OA^KqUG8Az=YtQftf;^2;G3fmD=Sh(De^#=lL z1HV|Dtu116cG?qh>1oOlR&Ofe3$jyM^i=&t-ULZKQV~w>%~{O^lMF=l28F8n3TTDh z2ht&Vo7aXxIg9aj18%VbDRKnK5SB0GIt>-rx-$(>WbR^g$FBa`Ut7KOpRf(&gMvIA zv}r9;Dy!Zj(8%QU=jVu`P&mYF$bLo0zg=~{i$lm)!M5VtYLfmp3u`3O^RuTivJ>ss%bD}3+dy?JYz8(Rb4`KoBUc_6Qr#zk*#J zapv7gMd-s5qF_^h^ID%{8cW^|PX|2$ue=5`1EGi*J+xk;`Yi`4a}zm1@(3^=`=>wd z0?522wR9Ix`|*)gb=0^%B}eihd~y~}C>rC$!M%#GqPw})em8@aPO&AF!`Y;&qG9C` zHSi0A@;NG6JE@9*ZOC|6!FO7Zyz#uf789Z5wz98e3QpAa*?_{FpBtM4b8kdWn;`s<%;2-mQSv-Y-qH*cDNTu=bdx#LM7l*Xt~g?!=mC$jSY(icx_c4q@DwH}zRg zYsIGO-`b{X5QZ=MI8Z3{*s`5l<3Ky10(U}C${)k;%i3+@udmym!j{7_Vuk#+EH7Rk zF||WKo#uRMG3wEJ5aSVz&z;J%!Nu$7=w8)65OJ96YN$MAq5EOPFcme%?4AmY=j1vz zIiHo1s!i9ti{DZL!RtR>C4_Ab(PBg=|0?vWZ&vK5Q6Ra2F~Le=9+pq8m@Z)4lvRn) zaRQc+Pju30O5R0l$WQtehR*wH@+803O{#L&k+~C!~BQZGOgP;oBJTU2{aY6d)6V z>cD%*ZK*&nw|JuIZ+vX>2><>>^R}3@*@;MCPe|H_U!QM3k99a>_XaR=NO*+g4lx2Y z#7Asyu2h@f679o5`p{1RxIJ|d2_=(ZDx^CxN6~E1-xMQIB+8|4R5O_8CskOw^nXUU zs|y%&1W_bLcRo66wK?5sC<-Vf=#Q%+DVs_6&t+^ySlrRx{O-si3>N2xymMI^Ubkq9 z2jU8m%A6|l*6;C3P}j%75)0p^MYT!?%uc3=$i);Hs|sG|n7y?%LK03eeZTDu16IQ> zL#iHIwgm1V`ZvA$F|$l0L*nJ2sjm(PeVBB*1%B?BH6i@wPWkpdNG1$~cWVX$gup94 zJ3D_3&2lggR1TKp+PJaza4D3LpSVMkVys?qKypiJGD5nGV^~hF7)DxFiSP?V-ty$} z+Se<0eAOA7;GJ#h0AV&c!t7xMg{fOndyTZcZ8o#J{b}Z{+ZI-`t~}ltxi=xu zp{g8pj%umj&N)w5MpT!8*Kds)ao`jy*}``SMQ>r-Ri*gjJy)`8dRu@V*7x4>>YpHi z5LV@+T{lx`USoBr|2^t{3p{!>xpi^VQI|mqSE#!fp|+R%OwwW%p`qC@{elfkc08U+LTfwV79I#?43%Iz}^8&5wYi ze*!MfituA!CMcH>j}efE`}fQEvprd3TaWA^+_c^FXQmuW1d#1geM>N8VSV?69q(Cb z114?BdNK&BASm-9YuV7&OlzFn`2BbD0ys`dmEK&B0WU-reQo1y{9A{63u2tz9L=l- zH~ZM$=DVsmgwwa)g}OAjeXnU)i)R>OLZeej2kh)Rk6Ggh+S7M?{wNP`!_O$lSvR&Z z=xCKx;0>$V-m#LCM`dBSZTm)1Q}!7CO}+!`4B!+e^_@HNn=@1!*7myQmh(^^o_riTJ_8tHFpSt++$vw2CCd%Q`-Ge#%UI6&(gPL-U z11h|NX4s|3-F1|qYUG;Lj@^6&I1L5^JS-WG@f=Jypq1?6R?f$zi@C||e360~PBxEd z3o$AbmFP4(59}Ta-e~sj3GfUIKkb}-VCzgBG8N|T3x-970~7B7zL6adm%R-aSD+LY ze?VolxsuNdY}2sS#*?fTW-vzgLwJ#sa*Z*YfO?Q0cnYI<_hv8K-%EKe;oxc19S2n= zuuD-W*5{dQ;m7yC3vxMHM>Fh3h>P%=?oknWUB#rgbc$_`#rZId2{fTi+?~4M;P?KL zU_NV$}9*_KMH}V@H>i z9ska6L980go3=_z7(boR{R7fJe`*`u@m(hp^cgB)qQYRCs(mlhNBmB`YH03$g}RR* zs;b8|piBQtdMh-%bo3?FboF^~J#()9>XY39pLy4pKPV@C&fR4GdBBcNYu#<1&1h7d zTiaK}_eS#uN2@)4aRc=??KVJ6PcTMs7GMVj>hUPNdqqzHe;#*dZ*qn6e?zez!Ff<# zv`#gRkfqgo$jWi4m5nKo_#+)yrDCf21v%uA{~dQRW05TQwQmbP=+FtEt8b*%7#w~Y zj7ze0lH3WzfnD_`xru#uL*@%>$Ee|n!z)8A=CQ{+O-*LV=1*D_|t5B$rl(SKMUBlStavbGx&S0Gts=Wb|qE2zq$tNK}HCObnr*!avg z=X^yYeb^dq0(`MEIKSWUuWqX~G!pR9FOIrIO`aJ>p2f9P_m{qCy?!s=@JNjQc48GZL$8-Adrzr0@ZPmF{_wlfY_q*n9%3GLk@kXb> z<}Pmm-UA?1K2f^OOw&->{X&0L9>cDWu2#FC(yR<@KC4Y$>J~Q6Q;{H@3+A>7ge2Lw z@O$&^Y5FlBV*niik;U4dhcHMSl^2LO?f!;$!dwC*g{=^|N^V;>`ZSnh8gSpaJuBko zY@)H)Ab9CJ3s~uKB*ge0KGFBK!M1-y?!w`gWcIl!)g(GO^r$-sW)LojQmgCcXe2t% zGxTy;FZ^fZSmZo#*nQ{94FavTkD=|;Bfbfn0>4F^4b3&5P{HFYbXbwW;w8A%sIgZp zv2+d;Pewb;#24mcSy4d<`9-q*9CcGHI{*htwk~8Z|I=NojW>XBZ}%ecDaFAFypbT} z7-pgj^)RBO-SC`X;C{Mc$V_%pmPZ(B+{|B6@vwvISLiE2&7W2ePK*o9PpADy!Sy-H zssN^X|5C~xwFm~}cS(cpcV}g@LnH+R;97~uqPMMY!i6$rdpINFV{B>^abtQT>0}EM zhE+2@FhUvhx9a{^jm6&OBHc>g*XHdw^1RnF$5mM6`DW)sDsYDd7{Op;K`0!*nmkT~ zwfh7XGKR^h>q1|SL^;3l^sa#kKhmpDwbL9VHz~2zo5G%SPx1AeRyJGT1kD&jd+=q9 znfjmVwrO~IV*mAZDNh*Ow9u>aV8f6+YnV@Sl$krxFOb$u;;in{hxlVDE5a#gdT5)* z{3;eU-ir9oekw%o5z{Tue&W<$|LKq$m<`S)5yD^b!5O;B%Q<8vhv!wg{ruQjBQT}CWil6k!Nm4fGyi--})#rtf)Ay4Q>2$Y4 zc)N`i8jbztgRF*&+)AgvsH&3pit1a4=Pn&J4D66iE^&GOHa;^XlB;gg+nUX_g)vYy zSrY`|9sMQ0%k_L^o0utk#P6B2EfA>@_m%3LZ8u|tv+>@OYTEkt&pwkAwED{uYK}DF zJQ8nzzWZA>=N+bl*!8WJ9G+~!rl(U*-|4qsw>RXZn|d=nq_hGvdl5uf1>e7R&=ABI zPcYb0cz_tzNiJCkACdRhv+}>7JXqnFjD5rVO>vjF`)jDNR;b^XxznOA=R~nUP7JgB z`*=#SYUXa;~LmH?9en2StvgQq*OEWdZ$56}{6bM;HYi~$A# zgcQ(y2}@oMP)L3_KiKNyu$le_fsBdosR{md=`c&N9F#*egN}_eaE0YjY?H4=*{yca zt-Ra=4h&%E)2?+RwpokODw4x=W59*HI9|P7Oa?KulNbsMA&hC=SJ4|4n=DIk)}Kcf$j0$(u|jS((gQ$vYGp-~ELX zNK3Zr;=8}fz5k{XkxsYviA^$MZAd~k2hwL8!)(>P+}5!GqO#-F$Is~f@)~L|)Cef2 zCfz0odNrq7{U6cu-A+ns+!tQY&11-7O5q)l%lga{TA3hUp{9k=!btl=@u$4yeCCua z&QM9HZbpJVVJC(e_dxsD7&@yY9UDig9ir(#WdmP@^J04?F~^9MPvrfVWA`D!R}&aE z0aQ>GeqY*#bJO_UM)%86z4?%`cO8OoDCw?N1Kf|(6bfK`Z>w$bI2-_qv9`H`1ayfO^kcG<-(vsyAX+cMJ=zOwLl@b3{8Txebp?%C*=T)yKT z2*-$)ncl$Uf5Piyy`d$m+p@%|{>14t6QnS@2(JEJ`javQf~_VpvpM|fogX}BvCoI_ zka|Ij1IWBOD$|C!Zb)q%CZZNfT<#Bx_=s12ScK#YGxnsFBM7eT)8u@qBJGBV^JndR zvc)K)QprcL$^srx^@T!7)L^o21{N6Yje8nwS6G-Lu#+{F>j|2$G&SDck(J+ZuB(GPrSf^_qI;K2XVwG)~o$w4?&I6o|CL z{Ox(hStTj6%VF-@(MXvNC*VuoslX&s0L&e9{jVb{JShbzQ5PU?5UFe@GuDSxHKzX@ zSpjW#k-GGDI(^j&W1bN3J+V?HiK%EXEnz<2SUjZ%4;e>jI+-WYiI&CcJA{fq1W)&m z>|Mf7$;fU28mUBkhd(ZYe8$CLg1la2v~_SfLj;+KMfnYL`$i-mR1}L@rjz$XDyEPi z^(8A-)N%<1eOpfxxJws8QH$q0?N8sArXcXaU+~;-n&(8yCEQpj+STrs{f+ z45d$vWVp^UqqS-P!)Ik141tr_9nO$u{L(^NXZCXq9Xg!K+N!g#rvLgWD_wQ!k))D9 zF#7JOBJ6b!!$J#(ir%+? zC-j>)SZ1OuC~sL+g1S$&O4(QS8WY|%D!qd5i*z&D!Nh-eAXW0AN{e4gmM<+U`=Trs zbz2lf-TnfRZ-FC)&%~(+o2Af$CgPGB!2DI^R6OZ)KIGyW`P;%sF2Kyl2dVT%Hv`cZ zx6?98A0qOJ^;PN7IhwPX{@gH%aP0(~{l~!KWrBPa&qJ&-h`z9+HF&}oNlW^U;53$F zD)#W2n(zxNN4$_X-;51kWW4k#aACALi{W>DbBuj?%x%9sp7(u4*J_$T#y@>6U&Mu7 za*H~#ze`zc?-UUWdgDdv)po7(E-0==Et15Fa0>lW@?9CH^{>Y z7|v|LW=8jI>vF@olyc@vfyG~G@C|s0#H`_?82A~R;foyDO;Z|aKfPHe8)#X%w$m603l%;7%w@$V+R63 z4H07u3B}OxiGdq5tH?*@WCLu?<<;o*a?dUG*7%P!PS1~lf+EFYc(%$44S9tg6l4#Y z)Q-;j+#Y}MF(fUPEohR2x(g6>dRYe}HJsHD(De-qdnau70z9qf_`Vh<-Y>x26SQ}3 z&Et91uacdE57-_t*mQST?_-o;fX|_b5zOR7;KgMqbxUGEoWPQNpj!|hbj)rlrG&Q$ zW!O&PkID7SM1GiOw#daKAjuu0|CFCa%}5pjU4er#-$u}FC3(UY@LfeP=SsV65gdMe zQ9tjN20#8&S8R`uHBrM+R75HOZ*ZWOf;1)9^NcOnUpgLi;L4|*+nz={T4!&oAA|q% z5wOs?kx#~Nb#v$2NuPhrW(eXJnlW-^jV|sZ4KD z{X{ET4*&Q5vp^+^79X7_G>%a}(c`vq!liTtZtJ4!H9){_mVOlSBV38KUABfRCt(O26uqnb-WoUe z#2G>!)&jWkI5GYyzFgk z5F{p@jsDw?=No1Q=REk<7^3CeiN3@F;yR7cgdL`*ZXZHLKqS`da<)6CurN4X+3(*_ z+KPfDYC#0EipsL%%5+qhgxD&@{OC}dEVDuW)c=)DVqO-xmOe5Wn9~eIMwG|rGx6#oiyZj~agOKq*2)PtlW%g#34YY@@$o7Q5=M!7 zlCZ~R-Zx{!dnVaIyz=Gx@q_Q$)XjG(F;ac%52G_-SxnP<(wf*yZVbPuTju8cYo6Rb zO=&D0$I|+@?R4aR1k)A$zCb;MeSryQbFK|3<>dbmDVW(v8qF}KbJXDJ*RVfypF6p- z^^0S=Frodis&jnGKqS4}&Ff0x!Y6fi?JijN`%gZY+{RU_52|xEMYD<4jU6wpHIlfy z<1A7bX`4X<;cN`Qk`<8M#E1JB8yw9t1Y%+EG!OboHO(%Hm>-~?uHcp27?L-=ca1t0 zPbuGJ`ZD%?*N74LwV#k_yumz-NQ^B^Y?T};p-d*{=vvQ0^=Ea>p9jnB3q zIj(x99eM{Zxigzi!$QDzs}0o%?6N@r;m~Ji-%k{Bb~dgL2N>IUKE?S(vMO{~7hQD0 z&{JklS0pU3?W+AdHZFDL*3Lz`l!Cnk^cN*;ZY&~z@AlL6HGviULWt>GN22BypXrHE z`)~WePArr;h){Vd1_1XFZeaYQf+TJ8dBRnGvU2e+_jty+!9kG;vMy+^GHqzG-x*WiF9hGUKHkSkOHuI7qC@^-0?PDC3n_Ei&>*Oe8cQ`z~8cjO#|4iOEx3#8BL{rod;$mV#%x73{WqJv1)8@gPDfJ4&W8 zn!D5|50}AuYkJd&C#GtuuhJbs3STK{&T&VJY)FieJ97-k^S3|tK-)(U1U)kp!&7oA zEg)vVR&v!6c+2L%T}A84p3KyjS`VaAVMv~i+ zF|oaE^Coia*Lcv_)~QuYL&^elxx=_8DAW!)L#>??mT4Ls37!^e;3 zvVK)Dq7P{ifhs2xep^NeqaRAsDz9peTP3#!*%FdO^aU$dPH`@7eOx2q#K&-v)!Q&ktzGKN2sxhl~sT+;P z!J+_qSK2@NeLw^4Qv}+I^tt<#fnx#pu7{Em(H=j`v@s^wU@NCLJU^Vv5J)(Xj^SQ3 zTD5rPw?X+jMX{{FFd0u(X;BALm8Opwt|`Pmycuj&0j5F5NF7CP*e?w%bEh9UOo5dz z(LhS5b$jBK*Lw68Q<)uyN>73eM-xBsU2mf5M{kC}ZdvxMnAGW~cg(lzhU^50C>dSh z>mLtgkTV?3qx9ZO1jw#}I5+mJL&j7nC6uSuRrqPCzbT`yGHY_7hUFKpF>C6tk@ zNI7y(#TYh>ir4VGY%!=TC$zz|>cd?UwItqLII^bI8zz>sVCp3wX_V?wE|j ztIvc|$|56)FS}N39I~tM^|QNzq0bpoT88HKS5YRr&ev7D=p?qW5HFj_&QDQlyx+rA zd{X3k=f=J2H%(k#pKm#wp8+qkgU6UK5C=~s>W(>VVwfPsL!^uaAwP*+gQ{J=u}*rf z>Ja;0#rtMd_ve9Lac9AoU;4MoBU_8;R89@|&qKeTtQi7^$DLL&`<|3DA=IZa6V2)H zrYK(b=r-f(Zwi}-iK+rabElUgEE#fx5=t1^`=Wqs9+U_+`{ukk=o~MR8QV zMe!5)iG?R+Z-24~yX%P<9#TW3mNh<=q=~G`B1w?UWpC}B*pU=@ezvYS3h?#>Ar=Fso=`Q-ditCh|5JW8oMx9?_A^mmwS_J(zubd3l zTX`p(kp6+v58k_L2ACOA(S z7|A19v0M?4>hB8?XF}o4AX9?<^&Ka9;Fpr3y+wQM`3U+?0i5IwxX;~^&VnhE%$`a9 zMxq~BZ2C6AijB;E-ou8fAh_|(2s8}TeGGx44RqPaqp^o91%d1yOYTfa0@zof?%KpL z;ukvd#TFdf(cWl3_5}pqA$L@y+q%9bNgkT-({Wl#q$|R$D=V+X9B_xuNF?rl1MXzh zqt|IN+QG=6Cy{Y9Tgl?|P{`vB+VSXscJ{e>d)!F&?vQlp6WkQm%Ocr4uxx$NqG<+b zWzHZK4iYrhpOLAWT1JuX4~7Qf#2ZucK|9PZKOYhC9(oz;s0rl3qefvwx>F>oK`uv# zYptFHc}*Cv#bUO$mGc2xrBoCHW|U;H`a1d8I)^8D1BtmtJ;^4U?f`0z!%7wAt;1_6 zNosk-D&8pyA*Gz_%#>JL`_)2(vd=dE=58h0zWxKaOJkp`3Fr``?^%_k+2h6Rvg^w0 z7$bix?o(6W?C%i*>7$6Ibp~&uSdnf9(XydLb09RxduGMPrSXg)_n=;va-24lrjNKu zuNd;e5Unrly#ockeR3`BnI%qKO?{3Y zmt{8JiJQ>wMMZ9zPLnTwyl;^ZLqm_f#_GJan>0*ZV*~>I1;ko-x=J>2BQCiyjE^eXWlFh4HWbO}*(j8YL z8I!$VnFEX*EYKmd4LrfnS-7}BXVhJ*KKA!H-ZE2fpW%;(N3WzJAbeefKP>e#MieNO zM3PrRJj$J;l3bw(Tie694Q@)6;A|lH*lRTa{VNaK{k8 zy$q+yv(tuF(vf6MAgz5;C2v(h_#CwxXGtR?GXCSsDE_*`COg0j@P?!H!>5Ut*2`uM z0&fLdn%TT+nXeFWHG>1bQ0i0RX;MmD#JZuriji$|8Epz1p63QDiPYNg2U8}t?dtJT zzKKL4809M%trbCAv33}c<>I6%3 zFPOr{k+XobvmHzXH7t~-1L zieOq*DCY}lupzxp@DTa9G|ow2SZ}yZ9wO8&jKzy{MNI?uP#jvksm6fbaufnonWx4D zzh-iv9=c)ca2sz0gE}JGA9W#9}iW82L5?p6))B>+13tW9_jvw%Hxtv=#1d z{Q%-z{(29w-`DhYbkZP*>Gng!m?4kmv?=cKLUZ-8`g4>)3e%6&ag0K%6^2*ozDy5A z+tu4aj=n`<1lh>v30^XrvL4#E3I<02l@8RV;&!In<83GJX5sIr2IkZ-6@kNasg_#9 zwxF0_MMAf}j~n(lWGo&>V;`}WeQ9W=OzN0ot*%~%Ul9{Kn#+`?m8-7YH3jm0MlF8$ zL`>~uaABIU()F4-0j+AmJuE`2<+?f`SAM24Yk_ z2`(&%z45?%>W8_j!9>gt6eaD9nEGs#e&LoiB61hoXks?~?YvlEAR2ECb(kW+Z4tfc z0mRceB?$8#$mVN5_X!nJ!yu>^b9&wyH!xbvx96^SKQYYy0d&;!OyH{12Swx^zZNm{S@sVL@8=26_Ry;mx^w8WJ+$S6kU9#?FJRpEoCO#L&TCfKJWE!@m`j(^ zd?PeE3L6Lq8w+Fvx=T0caAnc~L&)p=0Z>XmlIZzaxC5ExLRNU=@qS}XGP4Cx04!DqlpHG zFJG}+uo&F($2)s&(MhGM8z7XE<$i*z#6w&dD7nUkak%vchKj4ru1C(ZbTEKBm2vSU z+(kg?HPrMHq#qVurvkB;V6b}KZ>GR0Iy3V1HfQ=&N+8OwopzBMS+#Bi%w_at@}v!e z*=}{B)U-z`iDpBxqzem53cR>McZ9s^;P~bsRk^DQ(1T98_`t=&sEXJ7VSs!7GC#U8 z4}UnP6O%&%sd$+;+G-F=<_tb=Tn!@I6GhXGQ_?=_LJ%m;hy~>i`2<)M@8W8~s~(se z6(pQOC0V9BQiQ(J_?Er*Vx(|qsGz9NU9aCy`yNJyp--~}+g}96Lb9%suQfxM> zNoOW+4nB7F-5Yf_>7+4|W;+V{=88VhwL2J|>GB%s@c{4OChP(8GLI&eAJ8vg(l9}Sm!7$>mDE1nDNkf*Kni731DC=gAX%s3tUV5{uD_&qC3T_L z;rYaK48w=c##F#2gF7_!PKis;^W2^4;l|C^L=GZ}8~Edzl#*>Xka_pvN3>A8Trm*8 zLK+bTSs2E2n%&WCib^JL*KK4G366jho}UX@`#K?Fkr^jgQ87!d-vXz4psZr>#z2{% zHGpM;&2?wYXzI1T(k!-QfT*^j5dk+Ge&ER&xBa@}!VYwUl)`!?|BBpe;U;Tr%kI>M z$X%e_guI0k73(JS4`2AuW{vP5$CeE;U=vub%ekvhD{Al|&p=PABiqd|o zuXRR9!j`yor>P7MLq;#{MUe%sEIFs+t`7nC9)B2TkS+|V1#0tjuo+(e*l=)bVVK~c zmu_pI!+>@zh0Ef`tPOL?{gIJC{Kh@na68B10*|2HBx280avl3vfPZDS8}U%obu<$i zZPrlr?KkyDHFR+whibZ`qdI(|`!}Xt*>FJLoeRF2xMZY!(+tdR_*odSO>d>wdKj|^ z8Q#h{yRlx3N8We0S4uaR7sQM(T)qwn`hxDV*PA;{+~wg?^|Ei1&EjmfzFHI9W}qy! zrfIrmD4Yeay#Shu9z%$>gNGSH-7{03NX1c5F#h1L>Q#CuZTBg92(v^`6ZV#a@)T8!hw`9Csm=XC5BPUPg8$z=RmY97oi>O z`%5fy2h#dBgi?%Hg^Z642+^WqYn9u2G6C9ZWYL~@!P#q!R~)fp zo)XM66=%HmQYmE|DRe~@!{MQf@Y7{pE6g)eJ>Jp?l!T)BUu%!CVv43yg6mJbr7@C7 zXY)}f(Ss>nN1W<{{4~ahTW^`Mb3IE#Q3Du9II}py{U?^jt<;O`Tcv7XmlWz#1jK6u zL^R7qG#eX2YrhDEh&Iu!Z~^55Z1-WHMPKB$864okzrr^u@MHmPK*_opy?l^r^{Tvd zBh{Ej?5jCcSKiUsWCGKFK_=fl$iFxRsUc6ywU*SS@xqn_Q|Ts0?eXDM(|x-;_lV9Q z{q-nre`^g~Xp^J!=zU|gd@Po3h9M}TvnX+SV~BpQ?a2&PjZQemNaS61gieK3iL29t zPs?h)<<-o5**#^=!UuPWF0B_POY@Q(dNVd@>%Cf6BjyO2&gqLVxt>$rAQq1_=N%n6 z-H!UBfLz_!Bm7oQP!qx@MimeY3v`8abJjJ=!sQc4fJ{*O<;Y8gg~LuK@q&^Popw7q zu=)Xvpv(xtr+CQcq(I65MxicVOm4(4(;9Sb(E4ZoHh6m~7?Hs8Q*W_Z;T0 zCaO zcFaon5c--+u4gb_lCUtjvlS)r*=g_^gqPBC`Hz6ZSA%TxZ3Q6Nt`8<9O^^ z9o`}T*c;M9jKY(bUn2{@y(~PDH;y#E*|-Uvx@L9F*CKk%M*WU)5ovsE>eNbmqbHWH z_qgPg*ZeT6lCP)hARC^)vHdRW^qOg$%j=H#*(|_=3;+NStgtTp{Qp1c`BM{TC!n2; zmW?&j@8)CRdKgdx`koXF0KoVyp9Rp=hUM9QRK|3{cQd1k`%)gju+R7hPia7Qlqnr= zP|7!P#vCNQNxZ#+miYMTJ9D(r+r1y??H-1&qDuYLmEX%6n=@y@;gbZ}=w7K0T$!_! zzEUl_g{F#and1Eh=Pv^_F8vLk2bx^FKYhyqh<#;!1SWId%D~m?+TrRI{x&P?817C{ zEtsfsbz*3GdwzDweYQoSsArXeiLawAW00@gScd2eK7U%w+Q|D>FQz$R-Tjr-OQ_!) z7{)yp*#m0W0q7U!|7)OE-Kv`jYu{*u~Njo031q z(11}eEXpPGG^{=SWM{I5BmyitGGL`)Xs@73Crf=Mt(wbu48;QfjCY7LxM664!s6*& zpbfsIboc6mDK+FSSY%#dTMp9Oqqq59IcM6+7kvtDx!&u{7vRQ>c_8n4EF4R)a z!e>C@L{eO0xyut}c^-DSpPpw9w$m3OY1Zur!}MOIyqbym>@e$l*80SKA`q|2Q;-^0 zv2VckT}Z(FyeHn>tl*6;zZR3cRDr&4-1f8rY6`F$k^{zan1Y{x_N6&8@dY=k+7oNK zph9BGR0fJ$|JgnXS;6KD+jGX(U(9<|FJBe1b-l5jg-J7ARhD5}Q&sgyKU%k`I=3%V zACB;23m>_`NcbF>&b(|-;Z~NYoPQ5M#%Lyl+JR3B?(M`#Jqm3&GiFFE@<*&m53co9 z0c?-*#lbUoZoS7nqD=P`q7^C3WG+;}#qQ3aPjmK0?<$u-9zqLUqkivNOPo^-6m2xR zF(z}^Lc`*3W2~INZ9FUHSEMck*}W)yCo4qa9{*I_m?{)a(fMMT1=pmSeQLulWi7I~ z1bktXwmo~JS|^qL0R^}_?+gg+W7UefJ4Av&PSl(GyI|j^$42N7)ORERVXaO&KWQCx z;R)@kj0b3BG0wbth8589Z0w2G-Kdl@ptGFJ0RN_L)SkQ=Ttqv+D4d`o^icA)7W|KN zu_TkrFvE!M`1gbU<+- z@8uCj&EyjlD*zx0)HKBB*CUA%*pWll`7MTo3nH?w+wQHeFBlLv4fL@XIuJV;*|G4} z8~rfke&zAtQtjJ{EanE|FsaHmnkqR|u_lgxzBX3}*2ikw3zr-8>a%wY8KbiaF21X9 z*k>yirI{`V$QCk4fanyovG{T6kFmxq4m!#_Mk{Mgxq=$i0MD->M5EWjXLyq5HuDX) zfW;E^(BboMdXyyXT(l(iLAE7JjC@Hgzme(Oj@}h)X^14nsyL z%Q}?1TM%t9i$D#~zC%*pc3n!*pz` zd?#_U;irLx=;6l2Na-`-a!2n!1M|vyFXAGx&R$zEhEsV8goPdHJvd!SCpIs(D!pvF z%fz9Z=W#my9|i&%G%p%Bnp!*kVI087$cP_6->rcI00@8QVSLV68Mh=00U|3pf?6RR z@#b2fBb&0e6W_^{_I^j2x`ecJAM>iw+tc5-xu&Y)*pbz2%*$!e!wKl`mYZO644Op%9N~!Xb72mBI6Ah_u+^IXox}t16KLg<9*!1-^#L86fd_SL}8umSY9Y zSPHK(R5dBCg&<7%dql^yx6Xp2N6E5M5zx4W$f4LQi5ch%aR*#VM(zybH~K6v4gAFB zUiWJJz&EEsBmC(7+)}`cZ1LL|o@Vzfl+hP)1l}WCNdLE~Mg7gf{F_dJLOdSrpjaI9U`KHP~p~gWI5#>QF1tGM3G=q&99@&veo99Q(deF9+C$MH+$Z)cO zQJyEx$sAgc`SpMbF^OIl=kuE!f{FyzXkKmCM)EypeLLWzjqsIGnV203@h$Y1n^f7; zCr%`P*K3JGZ02ENwD~118oh3>k%ZLcQ(NG(@%l9i6XfK2nK1*e-aqBOv73(K6-;sA zBpcD7YaVTALa~mETMT=+dQI;gftV`K>E|grcH?L_X#4bXVHF|5ra@MVK#~EFxSyx2 z-o0Eun=XZ|g;|DLsS#5H#;{O4tJ%h74mCxp%aw^VDDf>|CS-|io3mO39)AxR^GCwG zw4Yj9m@C;6Cf2b$rcH~sXU%yp8#g8#Tkv`))VB%cQ&T(rBza^e-XlsHevB@;N@k1! z>_N2@cxIz|Omy1s+oUCMk0htdc}c_xoMHi_+3L$AgL4vRtMQT-r?5f&9TOvQ2*&#u z;JY0aZ&{PP^fDuxuZEN?BMp-n?`OvD`Lb_v;3XhkNK-IpacsTG?qA;!_b{}Oa0-$# zkW4x0T^On8!PaU*63kza9B3(?+;8m5`2v1l9Bn=AGo^xbT?q4Q^`|QaS|5NEY@p_D zZ|bD~tmno=0mU9>)F4-iN3jho4n{=S+#*D;h)+tOO}2VE2);wEP`5`dIB8&IMU=qM z1n#TO=;gL}YE9xXY`d5VNQ2O6M+W#~-Y@;*^e60X`1&E>GB-NkR&&wmg&jBq1k*+| z&}vpKqZ74I`{ZbqnLg1nh(V51%S$VueJpB;ko{Q3GgyCet+%%d#~)cTgE~Eg#BOO( z+WfFOZa|^O7`9*iq*E?if>SvehNJeO*wI8EJX{wZ#DL*%doHixXo@`$80vQVdtfFt?gsK5mqa?RHkpc z)Jk$-;FthR&=msvbH)E>UxK~>fB{HAf3>RsfPd-PUj_WT%kXRwex>IbO|9%7`|x|E ze_DuV%&$t_{>J>gQvX+FzeIS3K3B$8@VmG2T;ab9`JDS#dVZBv{@=L&%q+i)`{(5Q zjQ%C=$iJX}pNjv~_n)5AKV|9u)`GuF`LlpOJ(GV5;4l2|raTw$kDv0t6bbiS#Lx5} z)qrNqpHPrt|6LQ;opcK}pex@8WZM6e`IjcBK?VE`GqSgTo&^3xf7aQ*Rs^BlKxnkT zs{DoS27UTB+QiQBr=#&FToKP(JQ0-f5CngAI-k=oxG>0J`3L^fXZ@%Ce;)-PGx1k? zZU;2z?N8J4zx4EMe}1KBNN>d-*64Rl{b`S$fxm?P@jDPC_wUx}&t_X>PsK;N0swfn zP=9F6km@fH{=1An;Z+Unh6f<6@q;eT-<2+j{}=lhksU23#{3526Y$<$X5J5VvGp?#)B$%{OL~riFSc6`2GkI9taZt*&F?*b{yRQ zLjRxA|9PAHA3Q;KmLzmL}6haM!!Uv&e`O>Ip7zl$`!$I1-=5}E;| jB9Poa)32fT@^|1*_+NU50s#O3T+k0T=oNX*^Sl2Ca(=!9 diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index a4047271..e89a5ed7 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -508,7 +508,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["参考标的名义份额"] = swapPosition != null ? ((double)swapPosition.PosiQuantity).ToString("0.##") : "0"; - dic["参考标的基金管理人"] = ""; + + dic["参考标的基金管理人"] = underlying?.InvestAdvisorName ?? ""; var contractTypeId = (Context.GetContractTypes().FirstOrDefault(O => O.ContactType == "交易确认书接收")?.id) ?? 0; // 乙方联系人信息 var clientDuties = Context.GetClientDuties().Where(O => O.ContactTypeIdsInt.Contains(contractTypeId)).ToList(); diff --git a/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs b/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs new file mode 100644 index 00000000..6825c5ac --- /dev/null +++ b/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; + +namespace YLErp.UnitTestProject.Modules.UnderlyingModule +{ + [TestClass] + public class UnderlyingFundManagerMappingTest + { + [TestMethod] + public void InvestAdvisorName_MapsExistingFundManagerColumn() + { + var property = typeof(underlying_manager).GetProperty("InvestAdvisorName"); + + Assert.IsNotNull(property, "underlying_manager 应公开基金管理人属性 InvestAdvisorName"); + Assert.AreEqual("investadvisorname", property.GetCustomAttribute()?.Name); + Assert.AreEqual("基金管理人", property.GetCustomAttribute()?.DisplayName); + } + } +} diff --git a/YLErpDAL/Modules/AppModule/AppUpgrader.cs b/YLErpDAL/Modules/AppModule/AppUpgrader.cs index 583afcb1..8fe40d72 100644 --- a/YLErpDAL/Modules/AppModule/AppUpgrader.cs +++ b/YLErpDAL/Modules/AppModule/AppUpgrader.cs @@ -423,6 +423,8 @@ namespace YLErp.Modules.AppModule var exists = adminDb.Dictionaries.Select(n => n.Name).ToArray() .Select(n => n.Trim()).ToHashSet(); + // 记录本次新建的字典,确保初始项只写入一次,不覆盖后续人工维护结果。 + var addedDictionaryNames = new HashSet(); var root = XElement.Parse(YLErp.Resources.Properties.Resources.db_dictionaries); var itemsAll = root.Elements(); @@ -450,10 +452,51 @@ namespace YLErp.Modules.AppModule Name = name, Catalog = catalog }); + addedDictionaryNames.Add(name); } adminDb.SaveChanges(); + // 重点功能:解析 XML 中的初始项,仅为本次首次创建的字典生成 DictionaryItem。 + foreach (var dictionaryElement in itemsAll.Where(item => + item.Elements().Any() && addedDictionaryNames.Contains(item.Attribute("name")?.Value.TrimToEmpty()))) + { + var dictionaryName = dictionaryElement.Attribute("name")?.Value.TrimToNull(); + var dictionary = adminDb.Dictionaries.FirstOrDefault(item => item.Name == dictionaryName); + if (dictionary == null) + { + continue; + } + + var existingItemNames = adminDb.DictionaryItems + .Where(item => item.DictId == dictionary.Id) + .Select(item => item.Name) + .ToHashSet(); + var nextDictionaryItemIndex = adminDb.DictionaryItems + .Where(item => item.DictId == dictionary.Id) + .Max(item => (int?)item.IndexNum) ?? -1; + + // XML 中的排列顺序写入 IndexNum,页面下拉按该顺序展示。 + foreach (var itemElement in dictionaryElement.Elements()) + { + var itemName = itemElement.Attribute("name")?.Value.TrimToNull(); + if (itemName == null || existingItemNames.Contains(itemName)) + { + continue; + } + + adminDb.DictionaryItems.Add(new BaseOUDAL.DictionaryItem + { + DictId = dictionary.Id, + Name = itemName, + ShortName = itemElement.Attribute("short_name")?.Value.TrimToNull() ?? itemName, + IndexNum = ++nextDictionaryItemIndex + }); + existingItemNames.Add(itemName); + } + } + adminDb.SaveChanges(); + var marginTemplateDictionary = adminDb.Dictionaries.FirstOrDefault(item => item.Name == YLErp.Modules.SwapModule.SwapMarginTemplateConfigService.DictionaryName); if (marginTemplateDictionary == null) { diff --git a/YLErpWeb/Controllers/underlying_managerController.cs b/YLErpWeb/Controllers/underlying_managerController.cs index 851b449b..60dda498 100644 --- a/YLErpWeb/Controllers/underlying_managerController.cs +++ b/YLErpWeb/Controllers/underlying_managerController.cs @@ -473,6 +473,26 @@ namespace YLErp.Web.Controllers { return JsonError("资产类型 必须填写"); } + + model.EtfSubType = model.EtfSubType?.Trim(); + if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.EtfSubType)) + { + return JsonError("ETF 子类 必须填写"); + } + if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + { + + var isValidEtfSubType = DictionaryBLL.GetDictionaryItems("ETF 子类", model.EtfSubType).Any(); + if (!isValidEtfSubType) + { + return JsonError("ETF 子类 无效,请从字典选项中选择"); + } + } + if (model.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + + model.EtfSubType = null; + } if (ConsGlobal.InstrumentType.IsBond(model.UnderlyingInstrumentType)) { model.ExJson = JsonHelper.Serialize(model.Bond); diff --git a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml index 6ea28715..67e3501d 100644 --- a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml +++ b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml @@ -8,6 +8,8 @@ showDeltaS_S = PS.Config.Is长江 }; var bond = Model.Bond ?? new UnderlyingBond(); + // ETF 子类下拉直接读取前端可维护字典;空选项用于新增页面的必填提示。 + var etfSubtypeItems = YLErp.BLL.DictionaryBLL.GetList("ETF 子类", true, underlying.EtfSubType); var blocks = new[] { underlying.Block1, underlying.Block2, underlying.Block3, underlying.Block4, underlying.Block5 }; for (var i = 1; i <= 5; i++) { @@ -28,13 +30,14 @@ } } - .None, .Stock, .CommodityFutures, .Bonds { + .None, .Stock, .CommodityFutures, .Bonds, .Fund { display: none; } .form-Stock .Stock, .form-CommodityFutures .CommodityFutures, - .form-Bonds .Bonds { + .form-Bonds .Bonds, + .form-Fund .Fund { display: block; } @@ -323,6 +326,31 @@
+ + @* 仅基金及基金专户维护基金管理人 *@ +
+ + +
+ + @* 重点功能:ETF 子类由系统字典维护,使用 Fund 类控制显隐,并固定放在表单最后 *@ +
+ + * +
@@ -334,4 +362,4 @@
- \ No newline at end of file + diff --git a/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml b/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml index 731c26d4..2b17d61a 100644 --- a/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml +++ b/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml @@ -49,6 +49,14 @@ @Html.MyDisplayFor(m => m.UnderlyingEnName) @Html.MyDisplayFor(m => m.BBGTicker) + + @if (Model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + { + + @Html.MyDisplayFor(m => m.InvestAdvisorName) + @Html.MyDisplayFor(m => m.EtfSubType) + + } @Html.MyDisplayFor(m => m.UnderlyingType) @if (pageObj.showDeltaS_S) @@ -226,4 +234,4 @@ } -} \ No newline at end of file +} diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js index 7a8de500..a5a2724d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js @@ -13,7 +13,8 @@ }()); -const consSelect = ['MarketCode', 'UnderlyingInstrumentType', 'UnderlyingState', 'UnderlyingStatus', 'UpDownLimitType']; + +const consSelect = ['MarketCode', 'UnderlyingInstrumentType', 'UnderlyingState', 'UnderlyingStatus', 'UpDownLimitType', 'EtfSubType']; var autoUpDownLimit, autoVariety; @@ -102,11 +103,15 @@ $(function () { case "OtherBonds": classType = "Bonds"; break; + case "Fund": + // 重点功能:资产类型为基金及基金专户时,显示所有 Fund 专属字段。 + classType = "Fund"; + break; default: classType = type; break; } - $('#editForm').removeClass("form-None form-Stock form-CommodityFutures form-CommoditySpot form-Bonds").addClass("form-" + classType); + $('#editForm').removeClass("form-None form-Stock form-CommodityFutures form-CommoditySpot form-Bonds form-Fund").addClass("form-" + classType); }).trigger('change'); }); @@ -130,6 +135,11 @@ function saveData() { return main.alert("资产品种类型 必须填写!"); } + // 重点功能:ETF 子类只对基金及基金专户显示并必填,先在前端阻止无效提交。 + if (data.UnderlyingInstrumentType === "Fund" && !data.EtfSubType) { + return main.alert("ETF 子类 必须填写!"); + } + if (data.UnderlyingInstrumentType === "CommodityFutures" && !data.MaturityDate) { return main.alert("到期日 必须填写!"); } @@ -144,4 +154,4 @@ function saveData() { main.parentReloadData(); window.location.href = "/underlying_manager/underlying_managerView?enid=" + resp.obj.EncryptId; }); -} \ No newline at end of file +} From cd981527ab7e1c86ed4dd5555b0c72c163877c36 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 15:26:46 +0800 Subject: [PATCH 28/31] =?UTF-8?q?feat(web):=20EQD-6953=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=80=BC=E5=9F=9F=E9=97=B8=E9=97=A8=E8=A1=A5=E7=96=91?= =?UTF-8?q?=E4=BC=BC=E5=88=B0=E6=9C=9F=E5=80=BA=E5=88=B8=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E2=80=94=E2=80=94ytm=3D0=E4=B8=94=E5=87=80/=E5=85=A8=E4=BB=B7?= =?UTF-8?q?=E5=9D=87=E4=B8=BA=E9=9D=A2=E5=80=BC100=E4=B8=89=E8=80=85?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=88=90=E7=AB=8B(=E7=8E=B0=E5=AE=9E?= =?UTF-8?q?=E5=9C=A8=E5=B8=82=E5=88=B8=E5=87=A0=E4=B9=8E=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=83=BD,UAT=20060203.IB=E5=AE=9E=E8=AF=81jquantlib=E7=A9=BA?= =?UTF-8?q?=E7=8E=B0=E9=87=91=E6=B5=81=E9=80=80=E5=8C=96=E5=BD=A2=E6=80=81?= =?UTF-8?q?)=E6=97=B6=E6=8C=89=E6=97=A2=E6=9C=89'=E4=B8=8D=E5=9B=9E?= =?UTF-8?q?=E5=86=99+toast=E7=9C=9F=E5=AE=9E=E5=8E=9F=E5=9B=A0'=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E6=8F=90=E7=A4=BA=E6=A0=B8=E5=AF=B9=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E6=97=A5=E6=88=96=E6=89=8B=E5=B7=A5=E5=A1=AB=E5=86=99?= =?UTF-8?q?=EF=BC=9B=E5=BD=95=E5=85=A5=E9=A1=B5/=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E9=A1=B5=E5=85=B1=E7=94=A8=E6=AD=A4=E9=97=B8=E9=97=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=94=9F=E6=95=88=EF=BC=9B=E5=88=A4=E5=AE=9A=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=90=8E=E7=AB=AFBondCalcHepler.IsMaturedDeg?= =?UTF-8?q?enerate=E5=90=8C=E5=8F=A3=E5=BE=84(=E6=B3=A8=E9=87=8A=E5=B7=B2?= =?UTF-8?q?=E4=BA=92=E6=8C=87)=EF=BC=9B=E6=96=B0=E5=A2=9E4=E4=B8=AAjest?= =?UTF-8?q?=E7=94=A8=E4=BE=8B(=E5=91=BD=E4=B8=AD/=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E5=88=B8/ytm=3D0=E4=BD=86=E4=BB=B7=E6=A0=BC=E9=9D=9E=E9=9D=A2?= =?UTF-8?q?=E5=80=BC/=E4=BB=B7=E6=A0=BC=E9=9D=A2=E5=80=BC=E4=BD=86ytm?= =?UTF-8?q?=E9=9D=9E0)61/61=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/fe-tests/bondCalc.test.js | 18 ++++++++++++++++++ .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index 6595bdab..5fc69d0f 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -120,6 +120,24 @@ describe('边界:计算器未返回的值不覆盖、无变化不写', () => { }); }); +describe('疑似到期债券值域闸门:ytm=0 且 净/全价均为面值100(UAT 060203.IB 实证的退化形态)', () => { + test('三条件同时成立 → 命中闸门返回真实原因(不回写)', () => { + const resp = { cleanPrice: 100, dirtyPrice: 100, ytm: 0 }; + const msg = SwapCalc.getBondCalcErrorMessage(resp); + expect(msg).toContain('疑似已到期'); + expect(msg).toContain('手工填写'); + }); + test('正常券不命中 → 返回 null 放行回写', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 97.43, dirtyPrice: 100.00001369863016, ytm: 6.738278242318886 })).toBeNull(); + }); + test('ytm=0 但价格非面值(真实零息平价券)→ 不命中', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 99.5, dirtyPrice: 100, ytm: 0 })).toBeNull(); + }); + test('价格=面值 但 ytm 非 0(正常息票平价券)→ 不命中', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 100, dirtyPrice: 100.5, ytm: 3.2 })).toBeNull(); + }); +}); + describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 errCode 守卫)', () => { test('空响应 → 提示"无响应",且绝不回写', () => { expect(SwapCalc.getBondCalcErrorMessage(null)).toBe("债券计算器无响应,已保留手工输入"); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 90106724..d912b401 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -352,6 +352,13 @@ return "债券计算收益率量级异常(" + yd + "),已保留手工输入;" + "请检查估值日/价格输入或联系管理员核对债券计算服务"; } + // 疑似到期/无剩余现金流:估值日过到期日后 jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100 + // (三者同时成立在现实在市券中几乎不可能,UAT 060203.IB 实证)。宁可不回写并提示真实原因, + // 也不用退化值覆盖手工输入。与后端 BondCalcHepler.IsMaturedDegenerate 同口径,勿单边改动判定条件。 + if (yd === 0 && cp === 100 && dp === 100) { + return "该债券疑似已到期或无剩余现金流(收益率=0、净价/全价均为面值100),无法反算," + + "请核对债券到期日或手工填写净价/全价/收益率"; + } return null; } From b9968dc80ad2dd4821daed65b950e28cac623bbb Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 15:29:17 +0800 Subject: [PATCH 29/31] =?UTF-8?q?feat(swap):=20EQD-6953=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=80=BC=E5=9F=9F=E9=97=B8=E9=97=A8=E8=A1=A5=E7=96=91?= =?UTF-8?q?=E4=BC=BC=E5=88=B0=E6=9C=9F=E5=80=BA=E5=88=B8=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E2=80=94=E2=80=94IsResultAbsurd=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E6=94=AF:IsMaturedDegenerate(ytm=3D0=E4=B8=94=E5=87=80/?= =?UTF-8?q?=E5=85=A8=E4=BB=B7=E5=9D=87=E4=B8=BA=E9=9D=A2=E5=80=BC100,?= =?UTF-8?q?=E6=8A=BD=E4=B8=BApublic=E7=BA=AF=E5=87=BD=E6=95=B0=E4=BE=9B?= =?UTF-8?q?=E5=8D=95=E6=B5=8B)=E5=91=BD=E4=B8=AD=E5=8D=B3=E8=BF=94?= =?UTF-8?q?=E5=9B=9Enull+=E7=9C=9F=E5=AE=9E=E5=8E=9F=E5=9B=A0=E6=96=87?= =?UTF-8?q?=E6=A1=88=EF=BC=8C=E8=B5=B0=E6=97=A2=E6=9C=89'=E5=AE=81?= =?UTF-8?q?=E5=8F=AF=E4=B8=8D=E5=9B=9E=E5=86=99'=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E9=93=BE=EF=BC=9B=E5=88=A4=E5=AE=9A=E6=9D=A1=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AFswapCalc.js=E5=90=8C=E6=AC=BE=E9=97=B8?= =?UTF-8?q?=E9=97=A8=E4=B8=80=E8=87=B4(=E6=B3=A8=E9=87=8A=E4=BA=92?= =?UTF-8?q?=E6=8C=87)=EF=BC=9B=E6=96=B0=E5=A2=9E6=E4=B8=AA=E5=8D=95?= =?UTF-8?q?=E6=B5=8B(=E5=91=BD=E4=B8=AD/=E6=AD=A3=E5=B8=B8=E5=88=B8180205?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=80=BC/=E9=9B=B6=E6=81=AF=E5=B9=B3?= =?UTF-8?q?=E4=BB=B7/=E6=81=AF=E7=A5=A8=E5=B9=B3=E4=BB=B7/ytm=20null/IsRes?= =?UTF-8?q?ultAbsurd=E5=88=86=E6=94=AF=E6=8E=A5=E7=BA=BF)=E5=85=A8?= =?UTF-8?q?=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UnitTestProject/Modules/BondCalcHeplerTest.cs | 61 +++++++++++++++++++ YLErpDAL/Helpers/BondCalcHepler.cs | 22 ++++++- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 UnitTestProject/Modules/BondCalcHeplerTest.cs diff --git a/UnitTestProject/Modules/BondCalcHeplerTest.cs b/UnitTestProject/Modules/BondCalcHeplerTest.cs new file mode 100644 index 00000000..084f5236 --- /dev/null +++ b/UnitTestProject/Modules/BondCalcHeplerTest.cs @@ -0,0 +1,61 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Helpers; +using YLErp.Model; + +namespace UnitTestProject.Modules +{ + /// + /// EQD-6953 疑似到期债券值域闸门:IsMaturedDegenerate / IsResultAbsurd 分支覆盖。 + /// 场景来源:UAT 060203.IB(2006年国债,2026估值日已无剩余现金流)—— + /// jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100,errCode=0"成功但退化"。 + /// 与前端 swapCalc.js::getBondCalcErrorMessage 的同款闸门保持一致口径。 + /// + [TestClass] + public class BondCalcHeplerTest + { + [TestMethod] + public void 到期退化值_三条件同时成立_命中() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m }; + Assert.IsTrue(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void 正常券_不命中_按UAT实测180205IB() + { + var r = new CalBondResult { cleanPrice = 97.43300000000002m, dirtyPrice = 100.00001369863016m, ytm = 6.738278242318886m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void ytm为0但净价非面值_不命中_真实零息平价券场景() + { + var r = new CalBondResult { cleanPrice = 99.5m, dirtyPrice = 100m, ytm = 0m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void 价格为面值但ytm非0_不命中_正常息票平价券场景() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100.5m, ytm = 3.2m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void ytm为null_不命中() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = null }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void IsResultAbsurd_到期退化值_命中并带原因() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m }; + var ok = typeof(BondCalcHepler) + .GetMethod("IsResultAbsurd", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) + .Invoke(null, new object[] { r, null }); + Assert.IsTrue((bool)ok); + } + } +} diff --git a/YLErpDAL/Helpers/BondCalcHepler.cs b/YLErpDAL/Helpers/BondCalcHepler.cs index 4f7acd21..ed6b6bcd 100644 --- a/YLErpDAL/Helpers/BondCalcHepler.cs +++ b/YLErpDAL/Helpers/BondCalcHepler.cs @@ -162,7 +162,8 @@ namespace YLErp.Helpers /// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则: /// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 >1000 视为离谱); /// - 收益率(ytm,百分数口径):|ytm| > 100 视为爆炸(允许负利率债,但量级须合理); - /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。 + /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传); + /// - 疑似到期/无剩余现金流:见 。 /// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。 ///
private static bool IsResultAbsurd(CalBondResult data, out string reason) @@ -186,8 +187,27 @@ namespace YLErp.Helpers reason = "债券计算收益率量级异常(" + data.ytm.Value + "),请检查估值日/价格输入或联系管理员核对债券计算服务"; return true; } + if (IsMaturedDegenerate(data)) + { + reason = "该债券疑似已到期或无剩余现金流(收益率=0、净价/全价均为面值100),无法反算,请核对债券到期日或手工填写"; + return true; + } return false; } + + /// + /// 疑似到期/无剩余现金流债券:估值日晚于到期日时底层 jquantlib 对空现金流求解得 ytm=0、 + /// 净价/全价均为面值100——三者同时成立在现实在市券中几乎不可能(UAT 060203.IB 实证)。 + /// 判定条件与前端 swapCalc.js::getBondCalcErrorMessage 内同款闸门保持一致,勿单边改动。 + /// public 供单测直接覆盖(BondCalcHeplerTest)。 + /// + public static bool IsMaturedDegenerate(CalBondResult data) + { + return data != null + && data.ytm == 0m + && data.cleanPrice == 100m + && data.dirtyPrice == 100m; + } } } From 23a02e06c4d0a7f1a3775ed2eaca18906bed1224 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 15:41:56 +0800 Subject: [PATCH 30/31] =?UTF-8?q?fix(swap):=20EQD-5320=20=E5=8D=95?= =?UTF-8?q?=E8=A1=8C=E5=8F=91=E9=80=81Email=E6=8C=89=E9=92=AE=E6=8A=A5'?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E6=B5=81=E6=B0=B4id=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA'=E2=80=94=E2=80=94=E5=90=8C=E4=BA=8Bfa927b21?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=9A=84=E5=8D=95=E8=A1=8C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E4=BC=A0swap=5Fflow=5Fevent.EncryptId(=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E4=B8=B2,=E5=85=A8=E7=AB=99enid=E6=83=AF=E4=BE=8B)=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E7=AB=AF=E7=82=B9=E5=8E=9F=E5=8F=AA=E6=8C=89=E9=80=97?= =?UTF-8?q?=E5=8F=B7=E5=88=86=E9=9A=94=E6=95=B0=E5=AD=97=E8=A7=A3=E6=9E=90?= =?UTF-8?q?long.TryParse=E5=BF=85=E8=B4=A5=EF=BC=9B=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8=E5=85=BC=E5=AE=B9=E5=8F=8C=E5=BD=A2=E6=80=81=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=EF=BC=9A=E9=9D=9E=E6=95=B0=E5=AD=97=E5=8D=B3DecryptLo?= =?UTF-8?q?ng=E8=A7=A3=E5=AF=86(=E5=A4=B1=E8=B4=A5=E8=BF=940=E4=B8=8D?= =?UTF-8?q?=E6=8A=9B,=E9=9D=9E=E6=B3=95=E4=B8=B2=E5=BF=BD=E7=95=A5)?= =?UTF-8?q?=EF=BC=8C=E9=94=99=E8=AF=AF=E6=96=87=E6=A1=88=E9=99=84=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E5=85=A5=E5=8F=82=E4=BE=BF=E4=BA=8E=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=EF=BC=9B=E6=89=B9=E9=87=8F=E6=8C=89=E9=92=AE(=E6=95=B0?= =?UTF-8?q?=E5=AD=97id)=E8=A1=8C=E4=B8=BA=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Controllers/SwapTrade2Controller.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index bafb8382..479e70ab 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -1237,7 +1237,8 @@ namespace YLErp.Web.Controllers /// EQD-5320 批量发送结算确认书邮件——经服务端代理 bond-oms(BondOmsInterface_BaseUrl 出口), /// 替代前端直连 /trs_hub_api 反向代理(未配 nginx 的环境 404)。 ///
- /// 平仓流水id,逗号分隔 + /// 平仓流水id,逗号分隔;单个令牌兼容【数字】与【EncryptId 加密串】两种形态 + /// (批量按钮传网格数字id,单行按钮传 swap_flow_event.EncryptId——全站 enid 惯例) [HttpPost] public JsonResult BatchSendSettleEmail(string swapFlowEventIds) { @@ -1248,14 +1249,21 @@ namespace YLErp.Web.Controllers var ids = new List(); foreach (var s in swapFlowEventIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { - if (long.TryParse(s.Trim(), out var id)) + var token = s.Trim(); + long id; + if (!long.TryParse(token, out id) || id <= 0) + { + // 非数字令牌按 EncryptId 解密(解密失败/非法串返回0,不抛出) + try { id = DecryptLong(token); } catch { id = 0; } + } + if (id > 0) { ids.Add(id); } } if (ids.Count == 0) { - return JsonError("平仓流水id解析为空"); + return JsonError("平仓流水id解析为空(既非数字也非有效加密ID):" + swapFlowEventIds); } var result = new SwapEndConfirmService(CurUser).BatchSendSettleEmail(ids); return string.IsNullOrEmpty(result) ? JsonSuccess("发送成功") : JsonError(result); From 3f0984800aad643c8f1499c285d9dae37abcc967 Mon Sep 17 00:00:00 2001 From: tengyufan <1532636164@qq.com> Date: Fri, 21 Aug 2026 15:54:54 +0800 Subject: [PATCH 31/31] =?UTF-8?q?feat:=20=E6=94=B6=E7=9B=8A=E4=BA=92?= =?UTF-8?q?=E6=8D=A2=E5=BC=80=E5=B9=B3=E4=BB=93=E5=8F=8A=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E6=8E=A5=E5=85=A5OA=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=20#EQD-6425=20=E5=9B=BD=E8=81=94=E6=B0=91?= =?UTF-8?q?=E7=94=9F-=E8=A1=8D=E7=94=9F=E5=93=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=AE=A1=E6=89=B9=EF=BC=88=E6=8F=90=E4=BA=A4?= =?UTF-8?q?OA=E6=B5=81=E7=A8=8B=EF=BC=89=20-=20=E5=AE=A1=E6=89=B9=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=94=AF=E6=8C=81=E9=85=8D=E7=BD=AE=E2=80=9C=E5=85=B3?= =?UTF-8?q?=E8=81=94OA=E7=A7=BB=E5=8A=A8=E5=AE=A1=E6=89=B9=E2=80=9D=20-=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BAOA=E6=B5=81=E7=A8=8B=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=AF=B7=E6=B1=82=E3=80=81requestId=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8F=8A=E5=BC=82=E5=B8=B8=E5=A4=87=E6=B3=A8=20-=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81OA=E8=BD=AE=E8=AF=A2=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=80=9A=E8=BF=87=E3=80=81=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E5=92=8C=E5=BD=92=E6=A1=A3=E7=8A=B6=E6=80=81=20-=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=80=9A=E8=BF=87=E3=80=81=E6=8B=92=E7=BB=9D=E3=80=81?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E6=97=B6=E5=90=8C=E6=AD=A5=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E6=9C=AA=E7=BB=93=E6=9D=9FOA=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=20-=20=E5=A2=9E=E5=8A=A0=E5=AE=A1=E6=89=B9=E5=88=97?= =?UTF-8?q?=E8=A1=A8OA=E5=A4=87=E6=B3=A8=E5=B1=95=E7=A4=BA=E5=8F=8A?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0Mock=E8=81=94=E8=B0=83=E6=8E=A5=E5=8F=A3=20-?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9EOA=E5=AE=A1=E6=89=B9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=A1=A8=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=20-=20OA=E7=A7=BB=E5=8A=A8=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=8B=AC=E7=AB=8B=E6=9F=A5=E8=AF=A2=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=8F=8Aisnextflow=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=BD=B1=E5=93=8D=E4=BA=A4=E6=98=93=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=B9=A6OA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/Approvalprocess.cs | 4 + .../DBModels/trade_approval_oa_result.cs | 24 + YLErpDAL/DataBase/YLContext.cs | 3 +- YLErpDAL/Model/TradeLinq.cs | 1 + .../Modules/SwapModule/SwapTradeService.cs | 4 + .../SystemModule/ApprovalProcessService.cs | 5 +- .../DealModule/TradeConfirmService.cs | 4 + .../DealModule/TradeOpenService.cs | 25 + .../DealModule/TradeRevokeService.cs | 12 + .../TradeModule/TradeApprovalOAService.cs | 481 ++++++++++++++++++ YLErpDAL/Modules/TradeModule/TradeBLL.cs | 30 ++ .../Modules/TradeModule/TradeServiceBase.cs | 1 + .../Views/AccountOpeningProcess/Index.cshtml | 16 +- .../TradeApprovalOAJobController.cs | 25 + .../TradeApprovalOaMockController.cs | 110 ++++ YLErpWeb/appsettings.dev.json | 15 +- YLErpWeb/appsettings.local.json | 13 +- .../Scripts/app/system/Approvalprocess.js | 11 +- .../Scripts/app/trade/tradeApproval.js | 9 +- 19 files changed, 784 insertions(+), 9 deletions(-) create mode 100644 Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs create mode 100644 YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs create mode 100644 YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs create mode 100644 YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs diff --git a/Framework/YLErp.Core/DBModels/Approvalprocess.cs b/Framework/YLErp.Core/DBModels/Approvalprocess.cs index ee1339a2..5af5e653 100644 --- a/Framework/YLErp.Core/DBModels/Approvalprocess.cs +++ b/Framework/YLErp.Core/DBModels/Approvalprocess.cs @@ -70,5 +70,9 @@ namespace YLErp.DBModels ///
[DisplayName("节点触发条件")] public string triggerCondition { get; set; } + + /// 是否在进入该节点时关联国联民生 OA 移动审批。 + public bool isOaApproval { get; set; } + } } diff --git a/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs new file mode 100644 index 00000000..66be6224 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + /// + /// 衍生品交易审批节点对应的国联民生 OA 流程记录。 + /// + [Table("trade_approval_oa_result")] + public class trade_approval_oa_result : DBModelBaseV6 + { + public int trade_id { get; set; } + public int approval_process_id { get; set; } + public string process_type { get; set; } + public int? applicant_id { get; set; } + public string applicant_login_name { get; set; } + public string oa_fileid { get; set; } + public string status { get; set; } + public string oa_msg { get; set; } + public string request_payload { get; set; } + public string last_response { get; set; } + public DateTime? last_query_time { get; set; } + public bool is_valid { get; set; } + } +} diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index 78936a0c..eff0b712 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -406,6 +406,7 @@ namespace YLErp.BLL public DbSet clientMarginConfig { get; set; } public DbSet clientMarginDetail { get; set; } public DbSet tradeContractOaResult { get; set; } + public DbSet tradeApprovalOaResult { get; set; } public DbSet bondPayment { get; set; } public DbSet glms_risk_rule { get; set; } @@ -414,4 +415,4 @@ namespace YLErp.BLL public DbSet glms_risk_variable { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Model/TradeLinq.cs b/YLErpDAL/Model/TradeLinq.cs index 4862eabc..5234e178 100644 --- a/YLErpDAL/Model/TradeLinq.cs +++ b/YLErpDAL/Model/TradeLinq.cs @@ -64,6 +64,7 @@ namespace YLErp.Model public int? ProcessRoleId { get; set; } public string ProcessRoleName { get; set; } + public string OaRemark { get; set; } /// /// 验证当前审批角色是否有开仓(交易)复核权限 diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 8f6ecfa3..279d56b4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1250,6 +1250,10 @@ namespace YLErp.Modules.SwapModule { return; } + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(tradeObj, out var errorMessage)) + { + throw new ServiceException(errorMessage); + } var eventTypes = new List() { (int)SwapEventTypeEnum.修改交易, (int)SwapEventTypeEnum.新增交易, (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; var lastEvent = DbContext.swap_event.Where(x => x.SwapTradeId == intid && eventTypes.Contains(x.EventType) && !x.Invalid).OrderByDescending(o => o.OptTime).FirstOrDefault(); if (tradeObj.TradeStatus == ConsTrade.平仓待复核 || tradeObj.TradeStatus == ConsTrade.互换待复核) diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 4e9be366..e62f6c34 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -59,7 +59,8 @@ namespace YLErp.Modules.SystemModule parentNode = item.parentNode, approvalCondition = item.approvalCondition, conditionConfig = item.conditionConfig, - triggerCondition = item.triggerCondition + triggerCondition = item.triggerCondition, + isOaApproval = item.isOaApproval }).ToList(); DbContext.approvalprocess.AddRange(list); @@ -790,6 +791,8 @@ namespace YLErp.Modules.SystemModule /// 节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。 public string triggerCondition { get; set; } + + public bool isOaApproval { get; set; } } /// /// 审批流程修改节点 diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index a10d96ac..536504ed 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -263,6 +263,10 @@ namespace YLErp.Modules.TradeModule.DealModule td.OptName = UserName; td.OptDate = OptDate; int passStatus = new ApprovalProcessService(OptUser).CheckTradeApprovalStep(td, OptUser.UserId); + if (passStatus != 0 && passStatus != -3) + { + new TradeApprovalOAService(this).EnsureForCurrentNode(td); + } if (passStatus == 0) { td.TradeStatus = ConsTrade.确认成交; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 3755dc1b..9cdda1c5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -56,6 +56,23 @@ namespace YLErp.Modules.TradeModule.DealModule throw new ServiceException("该交易状态已变更,不能被审批,请先刷新页面"); } + if (!req.isFromOa && (req.status == "pass" || req.status == "reject")) + { + var currentNode = TradeProcessByCategory(td) + .Where(x => x.order == td.ProcessOrderId && (x.node == 0 || x.node == td.ProcessOrderBranch)) + .OrderByDescending(x => x.node == td.ProcessOrderBranch) + .FirstOrDefault(); + if (currentNode?.isOaApproval == true) + { + var oa = DbContext.tradeApprovalOaResult.FirstOrDefault(x => x.trade_id == td.id + && x.approval_process_id == currentNode.id + && x.is_valid + && (x.status == "提交成功" || x.status == "同步中" || x.status == "归档中")); + if (oa != null && !new TradeApprovalOAService(this).ArchiveForLocalAction(oa, out var errorMessage)) + throw new ServiceException(errorMessage); + } + } + if (req.status == "pass") { return TradePass(req, td); @@ -179,6 +196,13 @@ namespace YLErp.Modules.TradeModule.DealModule return result; } + // 关联 OA 的节点必须先成功创建 OA 流程,才允许本地审批推进到该节点。 + if (nextOrder.isOaApproval && td.TradeType == "收益互换") + { + // OA 创建是节点通过后的外部动作;明确失败只记录在 OA 结果表,不能回滚本地节点通过。 + new TradeApprovalOAService(this).CreateForNode(td, nextOrder); + } + //----------------------------------------------- // 继续审批流转 //----------------------------------------------- @@ -788,6 +812,7 @@ namespace YLErp.Modules.TradeModule.DealModule public bool notNeedOperationHistory; public bool isPartialExercise; public bool isExpire = false; + public bool isFromOa; } /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs index c543d745..661a8c6e 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs @@ -50,6 +50,11 @@ return; } + foreach (var td in revokeTrades) + { + ArchiveActiveOaOrThrow(td); + } + var tradeids = revokeTrades.Select(n => n.id); var revokeTrade_cashs = DbContext.trade_cash.Where(c => c.ValidState == "InValid" && !c.IsDeleted && tradeids.Contains(c.TradeId)).ToList(); revokeTrade_cashs.ForEach(x => @@ -94,6 +99,7 @@ var Withdraws = DbContext.trade.Where(t => tradeIds.Contains(t.id)).ToList(); foreach (var td in Withdraws) { + ArchiveActiveOaOrThrow(td); var changesTradeStatus = DbContext.TradeAuditLog.Where(x => (x.OptType == "确认交易" || x.OptType == "交易特批-确认交易") && x.TradeId == td.id).OrderByDescending(x => x.id)?.FirstOrDefault()?.Changes; td.TradeStatus = changesTradeStatus == ConsTrade.新增待确认 ? ConsTrade.新增待确认 : ConsTrade.修改待确认; td.CheckStatus = null; @@ -103,6 +109,12 @@ } DbContext.SaveChanges(); } + + private void ArchiveActiveOaOrThrow(trade td) + { + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(td, out var errorMessage)) + throw new ServiceException(errorMessage); + } /// /// 获取包含黑箱结构的子交易集合 /// diff --git a/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs new file mode 100644 index 00000000..c9342f95 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs @@ -0,0 +1,481 @@ +using BaseOUDAL; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Modules.ApiModule; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// 收益互换审批节点的国联民生 OA 对接。 + /// 本期只负责 HTTP 创建、查询和归档;审批结果由轮询任务回写本地流程。 + /// + public class TradeApprovalOAService : YLBaseService + { + private const string Pending = "提交成功"; + private const string Syncing = "同步中"; + private const string Ending = "归档中"; + private readonly IYcLogger _logger = LogFactory.GetLogger(); + + public TradeApprovalOAService(OptUserInfo userInfo) : base(userInfo) { } + public TradeApprovalOAService(YLBaseService baseService) : base(baseService) { } + + public bool IsSupported(trade trade) + { + return trade != null && trade.TradeType == "收益互换"; + } + + public trade_approval_oa_result CreateForNode(trade trade, approvalprocess node) + { + if (!IsSupported(trade) || node == null || !node.isOaApproval) + return null; + + var scenario = node.processType == "TradeProcess" + ? "开仓" + : (trade.TradeStatus == ConsTrade.平仓待复核 ? "平仓" : "收益结算"); + _logger.Info($"开始创建 OA 移动审批,交易:{trade.id},节点:{node.id},场景:{scenario}"); + var record = new trade_approval_oa_result + { + trade_id = trade.id, + approval_process_id = node.id, + process_type = node.processType, + applicant_id = UserId, + applicant_login_name = Config("Applicant"), + status = "提交中", + is_valid = true + }; + record.SetOpt(UserInfo); + DbContext.tradeApprovalOaResult.Add(record); + DbContext.SaveChanges(); + + try + { + var creator = Config("Creator"); + var applicant = Config("Applicant"); + if (string.IsNullOrWhiteSpace(creator) || string.IsNullOrWhiteSpace(applicant)) + throw new ServiceException("OA 配置缺少 Creator 或 Applicant"); + + var spr = GetSprLoginName(node); + var payload = BuildPayload(trade, creator, applicant, spr, scenario); + record.request_payload = JsonHelper.Serialize(payload); + var responseText = Post("SubUrl", payload); + record.last_response = responseText; + var response = JObject.Parse(responseText); + var responseData = response["data"] as JObject; + var responseCode = (int?)response["code"]; + var responseStatus = (string)response["status"]; + _logger.Info($"OA 创建流程原始响应,交易:{trade.id},节点:{node.id},响应:{responseText}"); + if ((int?)response["code"] != 0 || !string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + record.status = "提交失败"; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"] ?? "OA 创建流程失败"; + _logger.Error($"创建 OA 移动审批返回失败,交易:{trade.id},节点:{node.id},code:{responseCode},status:{responseStatus}"); + } + else + { + record.status = Pending; + record.oa_fileid = (string)responseData?["requestid"] ?? (string)responseData?["requestId"]; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"]; + if (string.IsNullOrWhiteSpace(record.oa_fileid)) + { + record.status = "提交失败"; + _logger.Error($"创建 OA 移动审批未返回 requestId,交易:{trade.id},节点:{node.id}"); + } + else + { + _logger.Info($"创建 OA 移动审批成功,交易:{trade.id},节点:{node.id},requestId:{record.oa_fileid}"); + } + } + } + catch (Exception ex) + { + record.status = "提交失败"; + record.oa_msg = ex.Message; + _logger.Error($"创建 OA 移动审批失败,交易:{trade.id},节点:{node.id}", ex); + } + DbContext.SaveChanges(); + return record; + } + + /// + /// 交易首次进入审批时,确保当前 OA 节点已创建对应流程。 + /// + public trade_approval_oa_result EnsureForCurrentNode(trade trade) + { + if (!IsSupported(trade) || trade.ProcessOrderId <= 0) + return null; + + var node = GetCurrentNode(trade); + if (node?.isOaApproval != true) + return null; + + var hasActiveRecord = DbContext.tradeApprovalOaResult.Any(x => x.trade_id == trade.id + && x.approval_process_id == node.id + && x.is_valid + && (x.status == "提交中" || x.status == Pending || x.status == Syncing || x.status == Ending)); + return hasActiveRecord ? null : CreateForNode(trade, node); + } + + /// + /// 本地撤回前归档交易仍在审批中的 OA 流程。 + /// + public bool ArchiveActiveForTrade(trade trade, out string errorMessage) + { + errorMessage = null; + if (!IsSupported(trade)) + return true; + + var records = DbContext.tradeApprovalOaResult + .Where(x => x.trade_id == trade.id + && x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (x.status == Pending || x.status == Syncing || x.status == Ending)) + .OrderBy(x => x.id) + .ToList(); + if (records.Count == 0) + { + _logger.Info($"本地撤回前未找到待归档 OA 流程,交易:{trade.id}"); + return true; + } + + foreach (var record in records) + { + if (!ArchiveForLocalAction(record, out errorMessage)) + return false; + } + return true; + } + + /// + /// 本地审批或撤回前归档 OA。通过条件更新抢占记录,避免与轮询回写同时推进同一节点。 + /// + public bool ArchiveForLocalAction(trade_approval_oa_result record, out string errorMessage) + { + errorMessage = null; + if (record == null) + return true; + + _logger.Info($"本地操作前开始归档 OA 流程,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + + if (!TryChangeStatus(record.id, Pending, Ending, null)) + { + _logger.Info($"本地操作前归档 OA 流程未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 审批结果正在同步,请稍后再试"; + return false; + } + + if (!ForceEnd(record.oa_fileid)) + { + TryChangeStatus(record.id, Ending, Pending, "OA 流程归档失败,等待重试"); + _logger.Error($"本地操作前归档 OA 流程失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 流程归档失败,暂不能执行本地审批操作"; + return false; + } + + TryChangeStatus(record.id, Ending, "已归档", "本地操作前已归档 OA 流程"); + _logger.Info($"本地操作前归档 OA 流程成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + return true; + } + + public JObject Query(string requestId) + { + var query = "requestId=" + Uri.EscapeDataString(requestId ?? string.Empty) + + "&systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + var queryUrl = Config("TradeApprovalQueryUrl"); + if (string.IsNullOrWhiteSpace(queryUrl)) + throw new ServiceException("OA 配置缺少 TradeApprovalQueryUrl"); + var url = queryUrl + (queryUrl.Contains("?") ? "&" : "?") + query; + var client = new HttpClientWrap(Config("BaseUrl"), 60); + return JObject.Parse(client.Get(url, null)); + } + + public bool ForceEnd(string requestId) + { + try + { + // 客户 OA 的 requestId 由 @RequestParam 接收,必须放在 URL QueryString。 + var response = JObject.Parse(Post("ForceEndUrl", new { }, requestId)); + var code = (int?)response["code"]; + var status = (string)response["status"]; + var responseData = response["data"] as JObject; + var resultCode = (string)responseData?["resultcode"]; + var success = code == 0 + && string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase) + && resultCode == "0"; + _logger.Info($"OA 强制归档返回,requestId:{requestId},code:{code},status:{status},resultcode:{resultCode},success:{success}"); + return success; + } + catch (Exception ex) + { + _logger.Error($"归档 OA 流程失败,requestId:{requestId}", ex); + return false; + } + } + + public List SyncPendingStatuses(string requestId = null) + { + var messages = new List(); + var staleAt = DateTime.Now.AddMinutes(-5); + var pending = DbContext.tradeApprovalOaResult + .Where(x => x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (string.IsNullOrWhiteSpace(requestId) || x.oa_fileid == requestId) + && (x.status == Pending || (x.status == Syncing && (x.last_query_time == null || x.last_query_time < staleAt)))) + .ToList(); + _logger.Info($"开始轮询 OA 移动审批状态,requestId:{requestId ?? "全部"},待查询数量:{pending.Count}"); + foreach (var record in pending) + { + var claimed = false; + try + { + _logger.Info($"查询 OA 移动审批状态,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + var response = Query(record.oa_fileid); + record.last_response = response.ToString(); + var flow = response["data"] is JArray data + ? data.FirstOrDefault(x => (string)x["requestId"] == record.oa_fileid) as JObject + : null; + var flowStatusType = (string)flow?["flowStatusType"]; + var flowNode = (string)flow?["flowNode"]; + var isReturned = flowStatusType == "0" && flowNode == "退回"; + // 1=批准;3=OA 批准后的自然归档。TRS 主动归档的记录不会进入本次查询。 + var isApproved = flowStatusType == "1" || flowStatusType == "3"; + _logger.Info($"OA 移动审批查询结果,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},flowStatusType:{flowStatusType},flowNode:{flowNode},判定:{(isReturned ? "退回" : isApproved ? "通过" : "等待")}"); + if (!isApproved && !isReturned) + { + record.last_query_time = DateTime.Now; + DbContext.SaveChanges(); + continue; + } + + DbContext.SaveChanges(); + if (!TryClaimForSync(record.id)) + { + _logger.Info($"OA 审批结果同步未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + continue; + } + claimed = true; + + var trade = DbContext.trade.Find(record.trade_id); + if (trade == null) + { + SetClaimedStatus(record.id, "同步失败", "交易不存在"); + continue; + } + if (GetCurrentNode(trade)?.id != record.approval_process_id) + { + SetClaimedStatus(record.id, "同步忽略", "当前审批节点已变化,忽略 OA 结果"); + continue; + } + var result = new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = trade.id, + status = isReturned ? "reject" : "pass", + comments = isReturned ? "OA 移动审批退回" : "OA 移动审批通过", + isFromOa = true, + notNeedOperationHistory = false + }); + if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) + { + SetClaimedStatus(record.id, Pending, result.ErrorMsg); + _logger.Error($"OA 审批结果回写本地失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},原因:{result.ErrorMsg}"); + } + else + { + SetClaimedStatus(record.id, isReturned ? "退回" : "通过", null); + _logger.Info($"OA 审批结果回写本地成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},结果:{(isReturned ? "退回" : "通过")}"); + } + messages.Add(record.trade_id + ":" + (string.IsNullOrWhiteSpace(result.ErrorMsg) ? (isReturned ? "退回" : "通过") : Pending)); + } + catch (Exception ex) + { + if (claimed) + { + SetClaimedStatus(record.id, Pending, ex.Message); + } + else + { + record.last_query_time = DateTime.Now; + record.oa_msg = ex.Message; + DbContext.SaveChanges(); + } + _logger.Error($"轮询 OA 审批状态失败,requestId:{record.oa_fileid}", ex); + } + } + _logger.Info($"OA 移动审批状态轮询结束,已处理数量:{messages.Count}"); + return messages; + } + + private string Post(string urlKey, object payload, string requestId = null) + { + var path = Config(urlKey); + if (string.IsNullOrWhiteSpace(path)) + path = urlKey == "ForceEndUrl" ? "/gateway/oaflow/forceEndOaFlow" : "/gateway/oaflow/createOaFlow"; + var query = "systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + if (urlKey == "ForceEndUrl") + query += "&requestId=" + Uri.EscapeDataString(requestId ?? string.Empty); + else + query += "&flowId=" + Uri.EscapeDataString(Config("MobileFlowId", "245103")) + + "&isnextflow=" + Uri.EscapeDataString(Config("TradeApprovalIsNextFlow", "0")); + var url = path + (path.Contains("?") ? "&" : "?") + query; + return new HttpClientWrap(Config("BaseUrl"), 60).PostJson(url, payload, null); + } + + private string Config(string key, string fallback = "") + { + if (key == "BaseUrl" || key == "SubUrl" || key == "TradeApprovalQueryUrl" || key == "ForceEndUrl") + { + var mockEnabled = bool.TryParse(AppManager.GetConfiguration()["TradeApprovalOaMock:Enabled"], out var enabled) && enabled; + var mockValue = AppManager.GetConfiguration()[$"TradeApprovalOaMock:{key}"]; + if (mockEnabled && !string.IsNullOrWhiteSpace(mockValue)) + return mockValue; + } + var value = AppManager.GetConfiguration()[$"oa_confg:{key}"]; + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + + private approvalprocess GetCurrentNode(trade trade) + { + if (trade == null) return null; + var processType = IsCloseScenario(trade) ? "CloseProcess" : "TradeProcess"; + return DbContext.approvalprocess + .Where(x => x.processType == processType + && x.order == trade.ProcessOrderId + && (x.node == trade.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == trade.ProcessOrderBranch) + .FirstOrDefault(); + } + + private static bool IsCloseScenario(trade trade) + { + return trade.TradeStatus == ConsTrade.平仓待复核 + || trade.TradeStatus == ConsTrade.行权待复核 + || trade.TradeStatus == ConsTrade.互换待复核; + } + + private bool TryClaimForSync(int recordId) + { + var now = DateTime.Now; + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, last_query_time = {1} " + + "WHERE id = {2} AND is_valid = 1 AND (status = {3} OR (status = {0} AND (last_query_time IS NULL OR last_query_time < {4})))", + Syncing, now, recordId, Pending, now.AddMinutes(-5)) == 1; + } + + private bool TryChangeStatus(int recordId, string expectedStatus, string targetStatus, string message) + { + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + targetStatus, message, recordId, expectedStatus) == 1; + } + + private void SetClaimedStatus(int recordId, string status, string message) + { + DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + status, message, recordId, Syncing); + } + + private string GetSprLoginName(approvalprocess node) + { + using var baseDb = DbContextFactory.GetErpBaseContext(); + var users = (from roleUser in baseDb.RoleUsers + join user in baseDb.SystemUsers on roleUser.UserId equals user.Id + where roleUser.RoleId == node.roleId && user.State == (int)UserState.Enabled + select user.LoginName).ToList(); + if (users.Count == 0) + throw new ServiceException($"关联 OA 的审批角色未配置启用用户,角色ID:{node.roleId}"); + if (users.Count > 1) + throw new ServiceException($"关联 OA 的审批角色配置了多个启用用户,角色ID:{node.roleId}"); + if (string.IsNullOrWhiteSpace(users[0])) + throw new ServiceException($"关联 OA 的审批用户未配置登录名,角色ID:{node.roleId}"); + return users[0]; + } + + private Dictionary BuildPayload(trade trade, string creator, string applicant, string spr, string scenario) + { + var positions = DbContext.swap_position + .Where(x => x.SwapTradeId == trade.id && x.IsInitial && !x.Invalid) + .ToList(); + var floatPosition = positions.FirstOrDefault(x => x.IsInitial && !string.IsNullOrWhiteSpace(x.UnderlyingCode)); + var underlyingName = floatPosition == null + ? string.Empty + : DbContext.underlying_manager.Where(x => x.UnderlyingCode == floatPosition.UnderlyingCode) + .Select(x => x.UnderlyingName) + .FirstOrDefault() ?? string.Empty; + var interestPositions = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode) + && x.InterestMode != (int)InterestModeEnum.初始预付金 + && x.InterestMode != (int)InterestModeEnum.追加预付金 + && x.InterestMode != (int)InterestModeEnum.Unknown).ToList(); + var marginPositions = positions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + || x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); + var tradeDate = trade.TradeDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var startDate = floatPosition?.PosiStartDate.ToString("yyyy-MM-dd") ?? string.Empty; + var maturityDate = floatPosition?.PosiMatuirityDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var contractDays = floatPosition?.PosiMatuirityDate == null + ? string.Empty + : ((floatPosition.PosiMatuirityDate.Value.Date - floatPosition.PosiStartDate.Date).Days).ToString(); + + var elementLines = new List + { + "交易日期=" + tradeDate, + "审批角色=衍生品负责人", + "交易编号=" + (trade.TradeNumber ?? string.Empty), + "对手方=" + (trade.ClientName ?? string.Empty), + "交易类型=" + scenario, + "浮动端多空头=" + (floatPosition == null ? string.Empty : floatPosition.PositionType == 1 ? "多头" : "空头"), + "利息端方向=" + JoinValues(interestPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")), + "簿记账户=" + (trade.AssetBookName ?? string.Empty), + "结构类型=" + (trade.StructureType ?? string.Empty), + "标的代码=" + (floatPosition?.UnderlyingCode ?? string.Empty), + "标的名称=" + underlyingName, + "期初收益率=" + (floatPosition?.InitYtm?.ToString("0.####%") ?? string.Empty), + "期初价格=" + (floatPosition?.PosiNetPrice.ToString("0.####") ?? string.Empty), + "行权方式=" + (trade.GetExerciseModeCn() ?? string.Empty), + "合约期限=" + contractDays, + "数量=" + (floatPosition?.PosiQuantity.ToString("0.####") ?? string.Empty), + "起始日=" + startDate, + "到期日=" + maturityDate, + "交易员=" + (trade.TraderName ?? string.Empty), + "支付日期=" + JoinValues(marginPositions.Select(x => x.HappenDate?.ToString("yyyy-MM-dd") ?? string.Empty)), + "期初预付金=" + JoinValues(marginPositions.Select(x => x.InterestPrincipalFix.ToString("0.00"))), + "期初预付金方向=" + JoinValues(marginPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")) + }; + + if (scenario == "开仓") + { + elementLines.Add("平仓金额="); + elementLines.Add("平仓日期="); + } + else + { + var eventType = scenario == "平仓" ? (int)SwapEventTypeEnum.平仓 : (int)SwapEventTypeEnum.互换; + var swapEvent = DbContext.swap_event.Where(x => x.SwapTradeId == trade.id && !x.Invalid && x.EventType == eventType) + .OrderByDescending(x => x.id).FirstOrDefault(); + var unwind = string.IsNullOrWhiteSpace(swapEvent?.EventData) ? null : JsonHelper.Deserialize(swapEvent.EventData); + elementLines.Add("平仓金额=" + (unwind?.SwapCloseAmount.ToString("0.##") ?? "")); + elementLines.Add("平仓日期=" + (unwind?.UnwindDate?.ToString("yyyy-MM-dd") ?? swapEvent?.ValueDate.ToString("yyyy-MM-dd") ?? "")); + } + + var elements = string.Join("
", elementLines); + var payload = new Dictionary + { + ["title"] = "衍生品系统TRS移动审批-" + trade.TradeNumber, + ["creater"] = creator, + ["applicant"] = applicant, + ["spr"] = spr, + ["applyDate"] = DateTime.Now.ToString("yyyy-MM-dd"), + ["elements"] = elements + }; + return payload; + } + + private static string JoinValues(IEnumerable values) + { + return string.Join(";", values.Where(x => !string.IsNullOrWhiteSpace(x))); + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 62e42170..004baef5 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -287,6 +287,13 @@ namespace YLErp.BLL .Select(a => new { a.order, a.roleId }) .ToList() .ToDictionary(a => a.order, a => a.roleId); + var approvalNodes = db.approvalprocess + .Select(a => new { a.id, a.processType, a.order, a.node }) + .ToList(); + var oaRecords = db.tradeApprovalOaResult + .Where(x => swapTradeIds.Contains(x.trade_id) && x.is_valid) + .Select(x => new { x.id, x.trade_id, x.approval_process_id, x.status, x.oa_msg }) + .ToList(); foreach (var tradeLinq in retListResult.rows) { @@ -305,6 +312,29 @@ namespace YLErp.BLL tradeLinq.ProcessRoleName = name; } + if (tradeLinq.TradeType == "收益互换") + { + var processType = tradeLinq.TradeStatus == "平仓待复核" || tradeLinq.TradeStatus == "行权待复核" || tradeLinq.TradeStatus == "互换待复核" + ? "CloseProcess" + : "TradeProcess"; + var currentNode = approvalNodes + .Where(x => x.processType == processType + && x.order == tradeLinq.ProcessOrderId + && (x.node == tradeLinq.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == tradeLinq.ProcessOrderBranch) + .FirstOrDefault(); + var oa = currentNode == null ? null : oaRecords + .Where(x => x.trade_id == tradeLinq.id && x.approval_process_id == currentNode.id) + .OrderByDescending(x => x.id) + .FirstOrDefault(); + if (oa != null) + { + tradeLinq.OaRemark = string.IsNullOrWhiteSpace(oa.oa_msg) + ? oa.status + : oa.status + ":" + oa.oa_msg; + } + } + if (tradeLinq.TradeType == "远期" || tradeLinq.TradeType == "掉期") { var option = db.trade_forward.FirstOrDefault(x => x.TradeId == tradeLinq.id); diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index 7d45a901..aa715929 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -120,6 +120,7 @@ namespace YLErp.Modules.TradeModule // 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。 // 若所有节点均不满足 → 直接审批通过(无需任何人审核)。 ApplyTriggerFromStart(tradeProcess, td, userId); + new TradeApprovalOAService(this).EnsureForCurrentNode(td); } /// diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index a2bbcced..476cf5e0 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -334,6 +334,9 @@ 审批规则 +
+ +
审批条件 @@ -424,7 +427,10 @@
审批规则 - + +
+
+
@@ -527,6 +533,9 @@ 审批规则
+
+ +
审批条件 @@ -617,7 +626,10 @@
审批规则 - + +
+
+
diff --git a/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs b/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs new file mode 100644 index 00000000..ffda966e --- /dev/null +++ b/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using YLErp.Helpers; +using YLErp.Model; +using YLErp.Modules.TradeModule; + +namespace YLErp.Web.WebAPI.Controllers +{ + /// 国联民生衍生品移动审批轮询入口,由 PowerJob 等外部调度器调用。 + [ApiController] + public class TradeApprovalOAJobController : ControllerBase + { + [HttpPost] + [Route("api/job/tradeApproval/syncOAStatus")] + public JsonResult SyncOAStatus([FromQuery] string requestId = null) + { + var configKey = AppManager.GetConfiguration()["JobConfig:ApiKey"]; + if (!string.IsNullOrEmpty(configKey) && Request.Headers["X-Job-Api-Key"].FirstOrDefault() != configKey) + return new JsonResult(new { code = 401, msg = "Invalid API Key" }); + + var user = new OptUserInfo(0, "系统移动审批同步", OptUserFrom.System); + var messages = new TradeApprovalOAService(user).SyncPendingStatuses(requestId); + return new JsonResult(new { code = 0, data = new { total = messages.Count, details = messages } }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + } +} diff --git a/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs b/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs new file mode 100644 index 00000000..e5bafa15 --- /dev/null +++ b/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using YLErp.Helpers; + +namespace YLErp.Web.WebAPI.Controllers +{ + /// + /// 仅供本地开发验证收益互换移动审批调用链的 OA 模拟接口。 + /// + [ApiController] + [Route("api/mock/tradeApprovalOa")] + public class TradeApprovalOaMockController : ControllerBase + { + private static readonly ConcurrentDictionary Flows = new(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + [HttpPost("createOaFlow")] + public IActionResult CreateOaFlow([FromBody] JsonElement payload) + { + if (!IsEnabled()) return NotFound(); + + var requestId = "mock-" + Guid.NewGuid().ToString("N"); + Flows[requestId] = new MockOaFlow { RequestId = requestId }; + return Json(new { code = 0, data = new { msg = string.Empty, requestid = requestId }, status = "SUCCESS" }); + } + + [HttpGet("getRequestData")] + public IActionResult GetRequestData([FromQuery] string requestId) + { + if (!IsEnabled()) return NotFound(); + + if (!Flows.TryGetValue(requestId ?? string.Empty, out var flow)) + return Json(new { code = 0, data = Array.Empty(), status = "SUCCESS" }); + + return Json(new + { + code = 0, + data = new[] { new { requestId = flow.RequestId, flowStatusType = flow.FlowStatusType, status = flow.Status } }, + status = "SUCCESS" + }); + } + + [HttpPost("forceEndOaFlow")] + public IActionResult ForceEndOaFlow([FromQuery] string requestId) + { + if (!IsEnabled()) return NotFound(); + + if (requestId != null && Flows.TryGetValue(requestId, out var flow)) + { + flow.FlowStatusType = "3"; + flow.Status = "归档"; + flow.IsEnded = true; + } + + return Json(new { code = 0, data = new { resultcode = "0" }, status = "SUCCESS" }); + } + + [HttpPost("setStatus")] + public IActionResult SetStatus([FromQuery] string requestId, [FromQuery] string flowStatusType, [FromQuery] string status = null) + { + if (!IsEnabled()) return NotFound(); + if (flowStatusType != "0" && flowStatusType != "1" && flowStatusType != "2" && flowStatusType != "3") + return BadRequest(new { code = 1, msg = "flowStatusType 只能为 0、1 或 2" }); + if (!Flows.TryGetValue(requestId ?? string.Empty, out var flow)) + return NotFound(new { code = 1, msg = "未找到 requestId" }); + if (flow.IsEnded) + return BadRequest(new { code = 1, msg = "OA 流程已归档" }); + + flow.FlowStatusType = flowStatusType; + if (!string.IsNullOrWhiteSpace(status)) + flow.Status = status; + return Json(new { code = 0, data = new { requestId, flowStatusType, status = flow.Status }, status = "SUCCESS" }); + } + + [HttpGet("flows")] + public IActionResult GetFlows() + { + if (!IsEnabled()) return NotFound(); + + return Json(new + { + code = 0, + data = Flows.Values.Select(x => new { requestId = x.RequestId, flowStatusType = x.FlowStatusType, status = x.Status, isEnded = x.IsEnded }), + status = "SUCCESS" + }); + } + + private static bool IsEnabled() + { + return bool.TryParse(AppManager.GetConfiguration()["TradeApprovalOaMock:Enabled"], out var enabled) && enabled; + } + + private JsonResult Json(object value) + { + return new JsonResult(value, JsonOptions); + } + + private sealed class MockOaFlow + { + public string RequestId { get; set; } + public string FlowStatusType { get; set; } = "0"; + public string Status { get; set; } = "创建"; + public bool IsEnded { get; set; } + } + + } +} diff --git a/YLErpWeb/appsettings.dev.json b/YLErpWeb/appsettings.dev.json index 57e00050..ecceba98 100644 --- a/YLErpWeb/appsettings.dev.json +++ b/YLErpWeb/appsettings.dev.json @@ -78,13 +78,26 @@ "BaseUrl": "http://10.250.202.40:13590", "SubUrl": "/gateway/oaflow/createOaFlow", "QueryUrl": "/gateway/oaflow/getRequestData", + "TradeApprovalQueryUrl": "/gateway/oaflow/getRequestDataPro", + "ForceEndUrl": "/gateway/oaflow/forceEndOaFlow", + "MobileFlowId": 245103, "DownloadFileUrl": "/gateway/oaflow/downloadSingleFile", "SystemToken": "QIkox8sv3rVLtiT1wmI4Dp8wSjX9D84Glkp121vRino9fLobWnDiy8ppmqJcv1676TYuxZsGrYelx0whRCMaPyums2VnzIYrpqCFf5tyc3/x22Elf25xgRmd5pH8tLcnPuVOjMrUf0GsoytQCGFkhnrPajhsKq/mSwYQ4OA=", //OA系统key "SystemName": "onederiv", - "IsNextFlow": 1, //是否自动提交 默认0 + "IsNextFlow": 1, // 交易确认书 OA 参数 + "TradeApprovalIsNextFlow": 0, // 收益互换移动审批 OA 参数 + "Creator": "guoj", // OA 流程创建人账号 + "Applicant": "guoj", // OA 流程申请人(sqr)账号 "FlowId": 779115, "UploadUrl": "http://10.250.202.40:13590/gateway/oaflow/uploadDoc" }, + "TradeApprovalOaMock": { + "Enabled": false, + "BaseUrl": "http://localhost:49462", + "SubUrl": "/api/mock/tradeApprovalOa/createOaFlow", + "TradeApprovalQueryUrl": "/gateway/oaflow/getRequestDataPro", + "ForceEndUrl": "/api/mock/tradeApprovalOa/forceEndOaFlow" + }, "GeneralSSO": { "enable": true, //是否启用通用SSO登录 "auth_url": "http://192.168.2.96:10117/sso/login", //SSO授权地址 diff --git a/YLErpWeb/appsettings.local.json b/YLErpWeb/appsettings.local.json index 01547973..a42f229b 100644 --- a/YLErpWeb/appsettings.local.json +++ b/YLErpWeb/appsettings.local.json @@ -77,10 +77,21 @@ "oa_confg": { "BaseUrl": "http://ip:port", "SubUrl": "", + "TradeApprovalQueryUrl": "", "UploadUrl": "/api/oa/upload", // OA附件上传接口路径 "SystemToken": "your-system-token", // OA系统鉴权Token "SystemName": "OTC_TRADE", // 调用方系统标识 - "IsNextFlow": 0 + "IsNextFlow": 1, // 交易确认书 OA 参数 + "TradeApprovalIsNextFlow": 0, // 收益互换移动审批 OA 参数 + "Creator": "", // OA 流程创建人账号 + "Applicant": "" // OA 流程申请人(sqr)账号 + }, + "TradeApprovalOaMock": { + "Enabled": false, + "BaseUrl": "http://localhost:49462", + "SubUrl": "/api/mock/tradeApprovalOa/createOaFlow", + "TradeApprovalQueryUrl": "/api/mock/tradeApprovalOa/getRequestData", + "ForceEndUrl": "/api/mock/tradeApprovalOa/forceEndOaFlow" }, "GeneralSSO": { "enable": true, //是否启用通用SSO登录 diff --git a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js index 68c4b3a8..08a3850d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js +++ b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js @@ -308,6 +308,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } var item2 = { @@ -321,6 +322,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } if (selectType == "2") { //交易 @@ -469,6 +471,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } var childNodes = thisObj.openItems.filter(x => x.Index > index); @@ -494,6 +497,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } thisObj.getRuleType(); @@ -956,7 +960,8 @@ var app = new Vue({ approvalGroupId: value.approvalGroupId, approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, - triggerCondition: value.triggerCondition + triggerCondition: value.triggerCondition, + isOaApproval: value.isOaApproval === true }); }); @@ -972,7 +977,8 @@ var app = new Vue({ approvalGroupId: value.approvalGroupId, approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, - triggerCondition: value.triggerCondition + triggerCondition: value.triggerCondition, + isOaApproval: value.isOaApproval === true }); }); // 需求②:了结/平仓/行权审批流程 @@ -990,6 +996,7 @@ var app = new Vue({ approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, triggerCondition: value.triggerCondition + ,isOaApproval: value.isOaApproval === true }); }); } diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js index 21bd8878..9904457a 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js @@ -79,6 +79,13 @@ var colModelGrid = [ width: 60, align: 'left' }, + { + name: 'OaRemark', + label: 'OA备注', + index: 'OaRemark', + width: 80, + align: 'left' + }, { name: 'id', label: 'id', @@ -1038,4 +1045,4 @@ function ProcessOptDateFilter(cellValue, options, rowObject) { } $("#DateFromTradeDate").parent().addClass("trade-wrap"); -$("#DateFromOptDate").parent().addClass("time-wrap"); \ No newline at end of file +$("#DateFromOptDate").parent().addClass("time-wrap");