diff --git a/.editorconfig b/.editorconfig index e2964ef3..9f76feac 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,10 @@ # 如果要从更高级别的目录继承 .editorconfig 设置,请删除以下行 root = true +# 所有文件:UTF-8 无 BOM(防止 bundle 拼接时在中间产生 ZWNBSP) +[*] +charset = utf-8 + # c# 文件 [*.cs] @@ -174,3 +178,6 @@ insert_final_newline = false # 拖尾逗号不添加 trailing_comma = none + +# 统一为无 BOM 的 UTF-8,避免编辑器写入 BOM 后在拼接 bundle 时产生 ZWNBSP 不可见字符 +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2af8a9b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 锁定前端 bundle 产物的行尾为 LF,避免 Windows 下 core.autocrlf 把重建脚本生成的 LF 文件 +# 误判为"已修改"(与仓库内已提交的 LF blob 一致)。Linux CI 本身无 autocrlf,此条无副作用。 +YLErpWeb/wwwroot/Statics/bundles/* text eol=lf diff --git a/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs b/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs index 8e00b1b4..5e597013 100644 --- a/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs +++ b/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs @@ -696,6 +696,12 @@ namespace YLErp.Configuration /// 确认书生成时是否自动用印 /// bool IsAutoSealAfterGeneratedBook { get; } + + /// + /// 收益互换浮动收益端默认为我方收取,并隐藏收支方向 + /// + bool SwapFloatingIncomeReceiveOnlyMode { get; } + /// /// 衡泰内证账号 /// @@ -725,4 +731,4 @@ namespace YLErp.Configuration /// string EodExecCallbackUrl { get; } } -} \ No newline at end of file +} diff --git a/Framework/YLErp.Core/Configuration/ProjectSettings.cs b/Framework/YLErp.Core/Configuration/ProjectSettings.cs index f88f6d9c..7b4fe8ca 100644 --- a/Framework/YLErp.Core/Configuration/ProjectSettings.cs +++ b/Framework/YLErp.Core/Configuration/ProjectSettings.cs @@ -811,6 +811,12 @@ namespace YLErp /// 确认书生成时是否自动用印 /// public bool IsAutoSealAfterGeneratedBook { get; set; } = false; + + /// + /// 收益互换浮动收益端默认为我方收取,并隐藏收支方向 + /// + public bool SwapFloatingIncomeReceiveOnlyMode { get; set; } = false; + /// /// 衡泰内证账号 /// @@ -842,4 +848,4 @@ namespace YLErp public string EodExecCallbackUrl { get; set; } = string.Empty; } } -} \ No newline at end of file +} diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index a926b273..f7db099e 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -241,21 +241,15 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["参考标的期初全价%"] = ((double)swapPosition.PosiGrossPrice * 100).ToString("N4"); dic["参考标的期初净价%"] = ((double)(swapPosition.PosiNetNoFeePrice ?? 0m) * 100).ToString("N4"); - // 固定收益率(年化)- ETF默认取"增强收益"腿的计息利率 + // 固定收益率(年化)仅对应增强收益腿;互换利率腿在模板中应留空。 bool isEtf = IsBondEtf(underlying?.UnderlyingCode ?? string.Empty); - if (isEtf) - { - var enhancePosition = swapPositions - .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "增强收益") - .FirstOrDefault(); - dic["固定收益率"] = enhancePosition != null - ? ((double)enhancePosition.InterestRateDefault * 100).ToString("N4") - : "0.0000"; - } - else - { - dic["固定收益率"] = "0.0000"; - } + var enhancePosition = swapPositions + .FirstOrDefault(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "增强收益"); + var swapRatePosition = swapPositions + .FirstOrDefault(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "互换利率"); + dic["固定收益率"] = enhancePosition != null + ? ((double)enhancePosition.InterestRateDefault * 100).ToString("N4") + : swapRatePosition != null ? "" : "0.0000"; // 获取客户适用的保证金率 var clientMarginRate = UnderlyingHelper.GetApplicableMarginRate( @@ -416,13 +410,10 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator // 利率类型判断(固定/浮动) swap_position interestMargin = null; - // ETF: 优先取"互换利率"腿 - if (isEtf) - { - interestMargin = swapPositions - .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "互换利率") - .FirstOrDefault(); - } + // 优先取互换利率腿,避免同时存在增强收益腿时受集合顺序影响。 + interestMargin = swapPositions + .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.category_tag == "互换利率") + .FirstOrDefault(); if (interestMargin == null) interestMargin = swapPositions .Where(x => ConsTrade.InterestModels.Contains(x.InterestMode) && x.interest_rest_days != null) @@ -442,11 +433,14 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["期末观察日"] = trade.ExerciseDate?.ToString("【yyyy】年【M】月【d】日") ?? ""; if (interestMargin != null) { - if (string.IsNullOrEmpty(interestMargin.FloatRateUnderlyingCode)) + // 增强收益腿在确认书中固定展示为固定利率,不展示其浮动利率标的或利差。 + if (interestMargin.category_tag == "增强收益" || string.IsNullOrEmpty(interestMargin.FloatRateUnderlyingCode)) { dic["利率类型"] = "固定利率"; dic["IsFixed"] = "☑"; //☑ - dic["固定利率"] = ((double)interestMargin.InterestRateDefault * 100).ToString("N4"); + dic["固定利率"] = interestMargin.category_tag == "增强收益" + ? "0.0000" + : ((double)interestMargin.InterestRateDefault * 100).ToString("N4"); dic["利差"] = ""; } else diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 52a1eaac..f1e87b49 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -42,6 +42,14 @@ namespace YLErp.Modules.SwapModule /// private sealed class StubEodPositionService : TestableSwapEodPositionService { + /// + /// 自动互换场景可注入固定流水,避免为了验证结算边界而依赖真实计息公式。 + /// 未赋值时仍走生产使用的真实 GetInterests 计算。 + /// + public List AutoInterests { get; set; } + + public eod_swap_position LastInterestCalculationEodPosition { get; private set; } + public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) { } @@ -60,6 +68,12 @@ namespace YLErp.Modules.SwapModule bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { + LastInterestCalculationEodPosition = eodPositions.SingleOrDefault(); + if (AutoInterests != null) + { + return AutoInterests; + } + return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, @@ -75,6 +89,28 @@ namespace YLErp.Modules.SwapModule return PersistedPositions.LastOrDefault(); } + // public 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。 + public eod_swap_position ExecuteSaveAutoEodInterestPosition( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, IntervalModel interval) + { + SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, + null, DealInterestsScenarioTest.Principal, 0m, 1m, DealInterestsScenarioTest.Principal); + return PersistedPositions.LastOrDefault(); + } + + public eod_swap_position ExecuteSaveAutoEodWithCloseInterestPosition( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, IntervalModel interval, decimal posiLongNotional, + decimal posiShortNotional, List flowEvents, + decimal closeNotional, bool autoSwap) + { + SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval, + posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, + DealInterestsScenarioTest.Principal); + return PersistedPositions.LastOrDefault(); + } + // public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射) public void ExecuteDealInterests( List interestList, List eodPositions, @@ -169,6 +205,27 @@ namespace YLErp.Modules.SwapModule }; } + /// + /// 创建自动互换利息流水。InterestAmount 是高精度应结,生产入口负责将实际结算收敛到两位。 + /// + private static swap_flow_event CreateAutoSwapFlowEvent(DateTime eventDate, decimal interestAmount) + { + return new swap_flow_event + { + id = 2002, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.自动互换, + EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestAmount = interestAmount, + TdInterestAmount = interestAmount, + InterestClosePnL = interestAmount, + InterestRate = FixedRate, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipal = Principal, + FloatRate = 0m, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + private static void AssertDecimal(decimal expected, decimal actual, string message = "") { var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); @@ -566,6 +623,151 @@ namespace YLErp.Modules.SwapModule #endregion + // ================================================================ + // 场景6:自动互换两位实际结算与到期清零 + // ================================================================ + + #region 场景6:自动互换尾差与最终结算 + + /// + /// [DI_AUTO_SETTLEMENT_001] 非最终自动互换:实际结算按两位,尾差继续保留在待实现。 + /// 0.0082 四舍五入后实际结算 0.01,待实现应为 0.0082 - 0.01 = -0.0018。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_001_非最终自动互换保留舍入尾差() + { + var service = new StubEodPositionService + { + AutoInterests = new List { CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.0082m) } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var result = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), + new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); + + AssertDecimal(0.01m, result.TdCloseInterest, "日终当日已实现必须使用两位实际结算金额"); + AssertDecimal(-0.0018m, result.InterestIncomeSum, "非最终结算的尾差必须继续留在待实现"); + AssertDecimal(0.01m, service.AutoInterests.Single().InterestAmount, "自动互换流水金额必须为两位"); + AssertDecimal(0.01m, service.AutoInterests.Single().InterestClosePnL, "资金汇总使用的流水损益必须为两位"); + } + + /// + /// [DI_AUTO_SETTLEMENT_002] 到期自动互换:仍按两位实际结算,但不存在后续计息时待实现必须清零。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_002_到期自动互换清零待实现() + { + var service = new StubEodPositionService + { + AutoInterests = new List { CreateAutoSwapFlowEvent(ExerciseDate, 0.0082m) } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var result = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(ExerciseDate.AddDays(-1), 0m), position, td, ExerciseDate, + new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 1 }); + + AssertDecimal(0.01m, result.TdCloseInterest, "到期自动结算仍按金额两位落库"); + AssertDecimal(0m, result.InterestIncomeSum, "到期最终自动结算后不得遗留待实现尾差"); + } + + /// + /// [DI_AUTO_SETTLEMENT_003] 自动互换后的后续部分平仓必须续接尾差和累计已实现。 + /// 7/7 自动互换将 0.008191780822 按 0.01 实际结算,留下 -0.001808219178; + /// 7/8 平仓一半后,待实现继续参与计算,7/9 全平时才清零。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_003_自动互换后部分平仓续接尾差和已实现() + { + var service = new StubEodPositionService + { + AutoInterests = new List + { + CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.008191780822m) + } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var autoResult = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), + new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); + + var firstCloseDate = new DateTime(2026, 5, 11); + var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m); + firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + firstCloseFlow.InterestPrincipal = 50m; + // 模拟 CalcUnwindInterest: 上日尾差 + 本次平仓后的高精度待实现。 + service.AutoInterests = new List + { + CreateAutoSwapFlowEvent(firstCloseDate, 0.006383561644m) + }; + service.AutoInterests[0].InterestPrincipal = 50m; + var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( + autoResult, position, td, firstCloseDate, null, 50m, 0m, + new List { firstCloseFlow }, 50m, false); + + AssertDecimal(-0.001808219178m, service.LastInterestCalculationEodPosition.InterestProfitSum, + "部分平仓计算必须带入自动互换遗留的待实现尾差"); + Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId, + "部分平仓计息必须按腿标识匹配上一日日终"); + AssertDecimal(0.006383561644m, firstCloseResult.InterestIncomeSum, + "部分平仓后待实现应延续历史尾差"); + AssertDecimal(0.02m, firstCloseResult.RealizedInterest, + "部分平仓后累计已实现应包含此前自动互换和本次平仓"); + + var finalCloseDate = firstCloseDate.AddDays(1); + var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, 0.01m); + finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + finalCloseFlow.InterestPrincipal = 50m; + service.AutoInterests = new List + { + CreateAutoSwapFlowEvent(finalCloseDate, 0.010479452055m) + }; + var finalCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( + firstCloseResult, position, td, finalCloseDate, null, 0m, 0m, + new List { finalCloseFlow }, 50m, false); + + AssertDecimal(0m, finalCloseResult.InterestIncomeSum, "全平后待实现应清零"); + AssertDecimal(0.03m, finalCloseResult.RealizedInterest, + "全平后累计已实现应包含自动互换和两次平仓"); + } + + /// + /// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零; + /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 + /// + [TestMethod] + public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零() + { + var settleDate = ExerciseDate; + var td = CreateTrade(); + var position = CreateInterestPosition(); + position.InterestPrincipalFix = 0m; + + // 事件未结算此前的 0.0082:到期日也必须保留待实现。 + var incompleteService = new StubEodPositionService(); + var incompleteEvent = CreateSwapFlowEvent(settleDate, 0m); + incompleteEvent.InterestPrincipal = 0m; + incompleteEvent.InterestRate = 0m; + var incompleteResult = incompleteService.ExecuteSaveEodInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, + new List { incompleteEvent }); + AssertDecimal(0.0082m, incompleteResult.InterestIncomeSum, "到期但未结清时不得丢弃历史待实现"); + + // 前端按两位提交 0.01,可覆盖 0.0082 的最终金额,允许清零。 + var finalService = new StubEodPositionService(); + var finalEvent = CreateSwapFlowEvent(settleDate, 0.01m); + finalEvent.InterestPrincipal = 0m; + finalEvent.InterestRate = 0m; + var finalResult = finalService.ExecuteSaveEodInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, + new List { finalEvent }); + AssertDecimal(0m, finalResult.InterestIncomeSum, "两位最终结算覆盖待实现后应清零"); + } + + #endregion + #endregion } } diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs index 8691bb11..0b9f7b37 100644 --- a/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs +++ b/UnitTestProject/Modules/SwapModule/InitUnwindTradingFeeTest.cs @@ -17,16 +17,29 @@ namespace YLErp.Modules.SwapModule return (decimal)method.Invoke(null, new object[] { position, unwindData }); } + private static decimal InvokeCalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) + { + var method = typeof(SwapDealService).GetMethod( + "CalcInitTradingFeePending", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "CalcInitTradingFeePending was not found"); + + return (decimal)method.Invoke(null, new object[] { oriPosition, position, unwindData }); + } + [TestMethod] public void 百分比模式_按平仓名义本金计算并四舍五入到两位() { var position = new swap_position { PosiFeeType = 0, - PosiTradingFeeUnit = 0.1234m + PosiTradingFeeUnit = 0.1234m, + PosiTradingFeePending = 1234.00m }; var unwindData = new UnwindData { + NotionalValue = 1_000_000m, CloseNotionalValue = 1_000_000m, CloseQty = 8888m }; @@ -42,10 +55,12 @@ namespace YLErp.Modules.SwapModule var position = new swap_position { PosiFeeType = 1, - PosiTradingFeeUnit = 1.235m + PosiTradingFeeUnit = 1.235m, + PosiTradingFeePending = 12.35m }; var unwindData = new UnwindData { + NotionalQty = 10m, CloseNotionalValue = 1_000_000m, CloseQty = 10m }; @@ -61,5 +76,73 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(0m, InvokeCalcInitTradingFee(null, new UnwindData())); Assert.AreEqual(0m, InvokeCalcInitTradingFee(new swap_position(), null)); } + + [TestMethod] + public void BaseRatePendingFeeUsesTheSameActualCloseAmountAsCloseFee() + { + var oriPosition = new swap_position + { + PosiFeeType = 1, + PosiTradingFeeUnit = 0.2m + }; + oriPosition.PosiTradingFeePending = 2000m; + var position = new swap_position { PosiTradingFeePending = 840m }; + var unwindData = new UnwindData { NotionalQty = 10000m, CloseQty = 3000m, CloseNotionalValue = 4200m }; + + var fee = InvokeCalcInitTradingFeePending(oriPosition, position, unwindData); + + Assert.AreEqual(600m, fee); + } + + [TestMethod] + public void BaseRatePendingFeeAllocatesManuallyAdjustedOriginalPendingFee() + { + var oriPosition = new swap_position + { + PosiFeeType = 1, + PosiTradingFeeUnit = 0.2m, + PosiTradingFeePending = 1500m + }; + var unwindData = new UnwindData { NotionalQty = 10000m, CloseQty = 3000m }; + + var fee = InvokeCalcInitTradingFeePending(oriPosition, new swap_position(), unwindData); + + Assert.AreEqual(450m, fee); + } + + [TestMethod] + public void PartialCloseTradingFeeAndPendingFeeUseTheSameRoundedOriginalFeeAllocation() + { + var oriPosition = new swap_position + { + PosiFeeType = 0, + PosiTradingFeeUnit = 1.1234m, + PosiTradingFeePending = 113.46m + }; + var unwindData = new UnwindData + { + NotionalValue = 10098m, + CloseNotionalValue = 4039.2m, + NotionalQty = 10000m, + CloseQty = 4000m + }; + + var tradingFee = InvokeCalcInitTradingFee(oriPosition, unwindData); + var pendingFee = InvokeCalcInitTradingFeePending(oriPosition, new swap_position(), unwindData); + + Assert.AreEqual(45.38m, tradingFee); + Assert.AreEqual(45.38m, pendingFee); + } + + [TestMethod] + public void LegacyPendingFeeKeepsCurrentPositionValueWhenNoBaseRateIsConfigured() + { + var oriPosition = new swap_position { PosiTradingFeeUnit = 0m }; + var position = new swap_position { PosiTradingFeePending = 840m }; + + var fee = InvokeCalcInitTradingFeePending(oriPosition, position, new UnwindData()); + + Assert.AreEqual(840m, fee); + } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index dd7726cb..ea9bf5c0 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -258,5 +258,36 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(500000.00m, savedData.CloseNotionalValue, "平仓名义本金应按两位小数写入事件"); Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数"); } + + [TestMethod] + public void UW_010_SwapUnwind_现金与两位利息事件保持一致() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 10.0049m, closeMethod: (int)CloseMethodEnum.全部平仓, + closePercent: 1m, closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + var floatEvent = new swap_flow_event + { + UnderlyingCode = "UT-FLOAT", PositionType = (int)PositionTypeFlag.Long, + EventType = (int)SwapEventTypeEnum.平仓, PayDirection = 1, MarkClosePnl = 10m + }; + var interestEvent = new swap_flow_event + { + PositionType = 0, InterestAmount = 0.0049m, TdInterestAmount = 0.0049m, + InterestClosePnL = 0.0049m, InterestFee = 0.0049m + }; + unwindData.FlowEvents.Add(floatEvent); + unwindData.FlowEvents.Add(interestEvent); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0m, interestEvent.InterestAmount); + Assert.AreEqual(0m, interestEvent.TdInterestAmount); + Assert.AreEqual(0m, interestEvent.InterestClosePnL); + Assert.AreEqual(0m, interestEvent.InterestFee); + Assert.AreEqual(10m, unwindData.SwapRealizedPnL); + Assert.AreEqual(-10d, service.ClientCashCalls[0].amount, 0.001d); + } } } diff --git a/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs new file mode 100644 index 00000000..14580fd3 --- /dev/null +++ b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs @@ -0,0 +1,70 @@ +using YLErp.Configuration; +using YLErp.DBModels; +using YLErp.Modules.TradeModule.DocGenerateModule; + +namespace YLErp.UnitTestProject.Modules.TradeModule.DocGenerateModule +{ + [TestClass] + public class GuolianContractNoGeneratorTest + { + [TestMethod] + public void IsGuolianSwapTrade_OnlyMatchesGuolianSwap() + { + var swapTrade = CreateSwapTrade(); + + Assert.IsTrue(GuolianContractNoGenerator.IsGuolianSwapTrade(swapTrade, CompanyEnum.国联)); + Assert.IsFalse(GuolianContractNoGenerator.IsGuolianSwapTrade( + new trade { TradeType = "香草期权" }, CompanyEnum.国联)); + Assert.IsFalse(GuolianContractNoGenerator.IsGuolianSwapTrade(swapTrade, CompanyEnum.国泰君安)); + } + + [TestMethod] + public void ShouldGenerateTradeNumberAfterSave_OnlyGeneratesForBlankTradeNumber() + { + var blankTrade = CreateSwapTrade(); + var manualTrade = CreateSwapTrade(); + manualTrade.TradeNumber = "MANUAL-001"; + + Assert.IsTrue(GuolianContractNoGenerator.ShouldGenerateTradeNumberAfterSave( + blankTrade, CompanyEnum.国联)); + Assert.IsFalse(GuolianContractNoGenerator.ShouldGenerateTradeNumberAfterSave( + manualTrade, CompanyEnum.国联)); + Assert.IsFalse(GuolianContractNoGenerator.ShouldGenerateTradeNumberAfterSave( + blankTrade, CompanyEnum.国泰君安)); + } + + [TestMethod] + public void BuildTradeNumber_ClientTrade_UsesNumericSequenceAndSanitizesUnderlyingCode() + { + var trade = CreateSwapTrade(); + trade.OpponentRole = "甲方"; + trade.TradeDate = new DateTime(2026, 7, 24); + trade.UnderlyingCode = "250210.IB"; + + var number = GuolianContractNoGenerator.BuildTradeNumber(trade, "IS", 3); + + Assert.AreEqual("GLMS-IS-20260724-FICC-03-250210IB", number); + } + + [DataTestMethod] + [DataRow(1, "A")] + [DataRow(26, "Z")] + [DataRow(27, "AA")] + public void BuildTradeNumber_NonClientTrade_UsesLetterSequence(int sequenceNo, string sequenceCode) + { + var trade = CreateSwapTrade(); + trade.OpponentRole = "乙方"; + trade.TradeDate = new DateTime(2026, 7, 24); + trade.UnderlyingCode = "180205.IB"; + + var number = GuolianContractNoGenerator.BuildTradeNumber(trade, "glmscounter", sequenceNo); + + Assert.AreEqual($"GLMS-glmscounter-20260724-FICC-{sequenceCode}-180205IB", number); + } + + private static trade CreateSwapTrade() + { + return new trade { TradeType = "收益互换" }; + } + } +} diff --git a/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookScopeTest.cs b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookScopeTest.cs new file mode 100644 index 00000000..5dcd3937 --- /dev/null +++ b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookScopeTest.cs @@ -0,0 +1,106 @@ +using YLErp.Configuration; +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Modules.TradeModule.DocGenerateModule; + +namespace YLErp.UnitTestProject.Modules.TradeModule.DocGenerateModule +{ + [TestClass] + public class GuolianSwapConfirmBookScopeTest + { + [TestMethod] + public void IsPreApprovalTrade_AllAllowedStatuses_ReturnsTrue() + { + var statuses = new[] + { + ConsTrade.新增待确认, + ConsTrade.修改待确认, + ConsTrade.审批中 + }; + + foreach (var status in statuses) + { + var trade = CreateTrade(status); + + Assert.IsTrue(GuolianSwapConfirmBookScope.IsPreApprovalTrade(trade, CompanyEnum.国联), status); + } + } + + [TestMethod] + public void IsPreApprovalTrade_NonGuolianSwapOrInvalidTrade_ReturnsFalse() + { + var confirmedSwap = CreateTrade(ConsTrade.确认成交); + var optionTrade = CreateTrade(ConsTrade.审批中, "香草期权"); + var invalidSwap = CreateTrade(ConsTrade.审批中); + invalidSwap.ValidState = ConsGlobal.InValid; + + Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(confirmedSwap, CompanyEnum.国联)); + Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(optionTrade, CompanyEnum.国联)); + Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(CreateTrade(ConsTrade.审批中), CompanyEnum.国泰君安)); + Assert.IsFalse(GuolianSwapConfirmBookScope.IsPreApprovalTrade(invalidSwap, CompanyEnum.国联)); + } + + [TestMethod] + public void OtherActiveReferencePredicate_OnlyMatchesOtherActiveTrade() + { + const string contractCode = "GLMS-TEST-20260728-FICC-04-000001"; + var predicate = GuolianSwapConfirmBookCleanupService + .BuildOtherActiveReferencePredicate(contractCode, 4) + .Compile(); + + Assert.IsFalse(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, true))); + Assert.IsTrue(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, true))); + Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, false))); + Assert.IsFalse(predicate(CreateRelation(7, "OTHER", ContractTypeEnum.Trade, true))); + Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Clearing, true))); + } + + [TestMethod] + public void TradeConfirmationRelationPredicate_MatchesActiveAndInactiveRelations() + { + const string contractCode = "GLMS-TEST-20260728-FICC-04-000001"; + var predicate = GuolianSwapConfirmBookCleanupService + .BuildTradeConfirmationRelationPredicate(4) + .Compile(); + + Assert.IsTrue(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, true))); + Assert.IsTrue(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Trade, false))); + Assert.IsFalse(predicate(CreateRelation(7, contractCode, ContractTypeEnum.Trade, true))); + Assert.IsFalse(predicate(CreateRelation(4, contractCode, ContractTypeEnum.Clearing, true))); + } + + [TestMethod] + public void ActiveOaResultPredicate_OnlyMatchesRejectedTradeActiveResult() + { + var predicate = GuolianSwapConfirmBookCleanupService + .BuildActiveOaResultPredicate(4) + .Compile(); + + Assert.IsTrue(predicate(new trade_contract_oa_result { trade_id = 4, is_valid = true })); + Assert.IsFalse(predicate(new trade_contract_oa_result { trade_id = 4, is_valid = false })); + Assert.IsFalse(predicate(new trade_contract_oa_result { trade_id = 7, is_valid = true })); + } + + private static trade CreateTrade(string status, string tradeType = "收益互换") + { + return new trade + { + TradeType = tradeType, + TradeStatus = status, + ValidState = ConsGlobal.Valid + }; + } + + private static trade_contract_r CreateRelation(int tradeId, string contractCode, string type, bool isValid) + { + return new trade_contract_r + { + TradeId = tradeId, + ContractCode = contractCode, + Type = type, + IsValid = isValid + }; + } + } +} diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index b9eb9d20..78936a0c 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -1,7 +1,4 @@ using BaseOUDAL; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.ComponentModel.DataAnnotations.Schema; -using System.Reflection; using YLErp.Core.DBModels; using YLErp.Model; @@ -17,19 +14,6 @@ namespace YLErp.BLL protected override void OnModelCreating(ModelBuilder modelBuilder) { - var clientBalanceMoneyConverter = new ValueConverter( - value => value.HasValue ? Convert.ToDecimal(value.Value) : null, - value => value.HasValue ? (double)value.Value : null); - var clientBalanceDaily = modelBuilder.Entity(); - foreach (var property in typeof(ClientBalanceDaily).GetProperties() - .Where(property => property.PropertyType == typeof(double?) - && property.GetCustomAttribute() == null)) - { - clientBalanceDaily.Property(property.Name) - .HasConversion(clientBalanceMoneyConverter) - .HasColumnType("decimal(20,6)"); - } - modelBuilder.Entity().HasKey(c => new { c.PGroup, c.PName }); modelBuilder.Entity().HasKey(c => new { c.ValueDate, c.OptionCode }); modelBuilder.Entity().HasKey(c => new { c.From, c.Key }); @@ -430,4 +414,4 @@ namespace YLErp.BLL public DbSet glms_risk_variable { get; set; } } -} +} \ No newline at end of file diff --git a/YLErpDAL/Model/SwapFlowEventExportModel.cs b/YLErpDAL/Model/SwapFlowEventExportModel.cs index d37cd6d0..d710bf22 100644 --- a/YLErpDAL/Model/SwapFlowEventExportModel.cs +++ b/YLErpDAL/Model/SwapFlowEventExportModel.cs @@ -99,6 +99,6 @@ namespace YLErp.Model public string OptLog { get; set; } - public string InitYtm { get; set; } + public decimal? InitYtm { get; set; } } } diff --git a/YLErpDAL/Modules/AppModule/AppUpgrader.cs b/YLErpDAL/Modules/AppModule/AppUpgrader.cs index ead11cb0..583afcb1 100644 --- a/YLErpDAL/Modules/AppModule/AppUpgrader.cs +++ b/YLErpDAL/Modules/AppModule/AppUpgrader.cs @@ -306,6 +306,7 @@ namespace YLErp.Modules.AppModule //----------------------------------------------- configService.AddDataIfNotExists("ProjectConfig", "Erp.IsAutoSealAfterGeneratedBook", "false", "bool", "确认书生成时是否自动用印(IsAutoSealAndUploadFiles勾选时生效)"); configService.AddDataIfNotExists("ProjectConfig", "Erp.ReportFileBeginNumber", "0", "int", "报送文件开始编号"); + configService.AddDataIfNotExists("ProjectConfig", "Erp.SwapFloatingIncomeReceiveOnlyMode", "false", "bool", "收益互换浮动收益端默认为我方收取,并隐藏收支方向"); RemoveUnUsed(configService); } @@ -465,9 +466,7 @@ namespace YLErp.Modules.AppModule .ToHashSet(); var nextIndex = adminDb.DictionaryItems .Where(item => item.DictId == marginTemplateDictionary.Id) - .Select(item => item.IndexNum) - .DefaultIfEmpty(-1) - .Max(); + .Max(item => (int?)item.IndexNum) ?? -1; foreach (var templateName in YLErp.Modules.SwapModule.SwapMarginTemplateConfigService.InitialTemplateNames) { if (existingNames.Contains(templateName)) diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs index 44683a41..d8011344 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodHandleSwapFlowService.cs @@ -10,6 +10,7 @@ using YLErp.Modules.CalculationModule; using YLErp.Modules.DataProviderModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; +using YLErp.Modules.TradeModule.DocGenerateModule; using YLErp.Modules.TradeModule.OrderModule; using YLErp.QdpModule; @@ -855,7 +856,14 @@ namespace YLErp.Modules.EodModule { if (string.IsNullOrWhiteSpace(td.TradeNumber)) { - td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); + if (GuolianContractNoGenerator.IsGuolianSwapTrade(td)) + { + GuolianContractNoGenerator.TryGenerateTradeNumberAfterSave(DbContext, td); + } + else + { + td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 7bf7f637..20237217 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -37,7 +37,6 @@ namespace YLErp.Modules.SwapModule protected virtual long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { NormalizeNotionalValues(unwindData); - NormalizeDeliveryPrices(unwindData.FlowEvents); return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } @@ -48,47 +47,49 @@ namespace YLErp.Modules.SwapModule unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } - private static int GetStorageDeliveryPriceRound(swap_flow_event flowEvent) + // 待实现利息会进入 decimal(30,12) 日终快照 + private const int InterestCalculationPrecision = 12; + + /// + /// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。 + /// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。 + /// + private static bool NormalizeSettledInterestAmounts(IEnumerable flowEvents, int eventType, string eventReason) { - if (ConsGlobal.InstrumentType.IsBond(flowEvent?.UnderlyingInstrumentType)) + if ((eventType != (int)SwapEventTypeEnum.平仓 && eventType != (int)SwapEventTypeEnum.互换) + || eventReason == "系统操作_自动平仓") { - return ConsGlobal.PriceRound; + return false; } - if (string.IsNullOrEmpty(flowEvent?.UnderlyingCode)) + + foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode))) { - return ConsGlobal.SwapDeliveryPriceRound; + // 只处理利息腿;浮动腿损益在日终快照入口统一按两位落库。 + flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } - var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent?.UnderlyingCode); - return underlying?.IsBond() == true ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; + return true; } - private static void ValidateDeliveryPrices(UnwindData unwindData) + // 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。 + private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason) { - if (unwindData.FlowEvents == null) + if (!NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason)) { return; } - foreach (var item in unwindData.FlowEvents.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode))) - { - var roundedPrice = Math.Round(item.TradingAmountAvg, GetStorageDeliveryPriceRound(item), MidpointRounding.AwayFromZero); - if (item.TradingAmountAvg != roundedPrice) - { - throw new ServiceException($"期末交割价最多保留{ConsGlobal.SwapDeliveryPriceRound}位小数"); - } - item.TradingAmountAvg = roundedPrice; - } - } - private static void NormalizeDeliveryPrices(IEnumerable flowEvents) - { - if (flowEvents == null) + if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode))) { + CalcCloseAmount(unwindData); return; } - foreach (var item in flowEvents.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode))) - { - item.TradingAmountAvg = Math.Round(item.TradingAmountAvg, GetStorageDeliveryPriceRound(item), MidpointRounding.AwayFromZero); - } + + unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); } /// 保存所有变更(生产: DbContext.SaveChanges;测试: 空操作) @@ -306,7 +307,7 @@ namespace YLErp.Modules.SwapModule floatEvent.ContractSize = position.ContractSize; floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; var ratio = position.PosiDirection == (int)SwapDirectionEnum.收取 ? -1m : 1m; - floatEvent.TradingFeePending = position.PosiTradingFeePending; + floatEvent.TradingFeePending = CalcInitTradingFeePending(oriPosition, position, unwindData); floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; floatEvent.InterestMode = position.InterestMode; floatEvent.ClientId = td.ClientId; @@ -323,12 +324,29 @@ namespace YLErp.Modules.SwapModule return 0; } - if (oriPosition.PosiFeeType == 1) + if (oriPosition.PosiTradingFeeUnit == 0) { - return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + return 0; } - return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + var closeBase = oriPosition.PosiFeeType == 1 ? unwindData.CloseQty : unwindData.CloseNotionalValue; + var originalBase = oriPosition.PosiFeeType == 1 ? unwindData.NotionalQty : unwindData.NotionalValue; + if (originalBase <= 0) + { + return 0; + } + + return Math.Round(oriPosition.PosiTradingFeePending * closeBase / originalBase, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + private static decimal CalcInitTradingFeePending(swap_position oriPosition, swap_position position, UnwindData unwindData) + { + if (oriPosition == null || unwindData == null || oriPosition.PosiTradingFeeUnit == 0) + { + return position?.PosiTradingFeePending ?? 0; + } + + return CalcInitTradingFee(oriPosition, unwindData); } /// /// 校验上日是否收盘 @@ -890,8 +908,8 @@ namespace YLErp.Modules.SwapModule } // 四舍五入并赋值 - interest.InterestAmount = Math.Round(interestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - interest.TdInterestAmount = Math.Round(tdInterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 计算InterestClosePnL(方向:收取=1为正,支付=-1为负) var interestRatio = position.InterestDirection == 1 ? 1m : -1m; interest.InterestClosePnL = interest.InterestAmount * interestRatio; @@ -1006,8 +1024,8 @@ namespace YLErp.Modules.SwapModule CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount); } - interest.InterestAmount = Math.Round(InterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - interest.TdInterestAmount = Math.Round(TdInterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.InterestClosePnL = interest.InterestAmount * interestRatio; } if (add) @@ -1091,8 +1109,8 @@ namespace YLErp.Modules.SwapModule // consumedInterest 为绝对值口径(swap_flow_event.InterestAmount 之和),与 interest 口径一致。 interest -= consumedInterest; tdinterest -= consumedInterest; - InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); } /// @@ -1152,8 +1170,8 @@ namespace YLErp.Modules.SwapModule tdinterest += tdinterest1; } } - InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); } /// @@ -1222,8 +1240,8 @@ namespace YLErp.Modules.SwapModule tdinterest = tdinterest1; } flowEvent.FloatRate = Convert.ToDecimal(floatRate); - InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); } /// @@ -1275,8 +1293,8 @@ namespace YLErp.Modules.SwapModule tdinterest /= annualDays; } - InterestAmount = Math.Round(interestProfitSum+interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + InterestAmount = Math.Round(interestProfitSum + interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); } /// @@ -1291,8 +1309,8 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - ValidateDeliveryPrices(unwindData); NormalizeNotionalValues(unwindData); + NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 @@ -1696,6 +1714,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; + NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); var trans = DbContext.Database.BeginTransaction(); try { @@ -1739,6 +1758,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; + NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); var trans = DbContext.Database.BeginTransaction(); try { @@ -1776,9 +1796,9 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - ValidateDeliveryPrices(unwindData); NormalizeIncomeUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); + NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易 ExecuteInTransaction(() => @@ -1817,14 +1837,12 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); - NormalizeDeliveryPrices(swapEvent.unwindData.FlowEvents); if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(swapEvent.unwindData); ValidateIncomeValueDate(swapEvent.unwindData, td); } var flowList = FindFlowEventsByEventId(swapEvent.id); - NormalizeDeliveryPrices(flowList); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate); if (swapEvent.unwindData.SwapMarginAmount != 0) @@ -1867,13 +1885,13 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - ValidateDeliveryPrices(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; + NormalizeManualSettlementAmounts(unwindData, eventType, eventType == (int)SwapEventTypeEnum.互换 ? "系统操作_互换" : "系统操作_平仓"); // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 // 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错 @@ -1927,6 +1945,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } var flowList = new List(unwindData.FlowEvents); + NormalizeSettledInterestAmounts(flowList, eventType, eventResason); unwindData.FlowEvents.Clear(); // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 @@ -2026,7 +2045,10 @@ namespace YLErp.Modules.SwapModule position.InterestFeePending += interest.InterestFee; if ((interest.InterestMode == (int)InterestModeEnum.追加预付金 || interest.InterestMode == (int)InterestModeEnum.初始预付金) && eventType == (int)SwapEventTypeEnum.平仓) { - position.InterestPrincipalFix -= interest.InterestPrincipal; + position.InterestPrincipalFix = Math.Round( + position.InterestPrincipalFix - interest.InterestPrincipal, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 9875528a..8ca05ad6 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -47,11 +47,72 @@ namespace YLErp.Modules.SwapModule : ConsGlobal.SwapDeliveryPriceRound; } + // 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。 + private const int EodInterestStoragePrecision = 12; + + private static decimal RoundMoney(decimal value) + { + return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + } + + private static decimal RoundEodInterest(decimal value) + { + return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero); + } + + /// + /// 仅在写入 eod_swap_position 前统一快照精度。 + /// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位, + /// 使部分结算后的尾差可继续参与后续计息。 + /// + private static void NormalizeEodPositionForStorage(eod_swap_position position) + { + if (string.IsNullOrEmpty(position.UnderlyingCode)) + { + // 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。 + position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix); + position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault); + position.InterestFeePending = RoundEodInterest(position.InterestFeePending); + position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal); + position.TdInterestRate = RoundEodInterest(position.TdInterestRate); + position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome); + position.TdInterestFee = RoundEodInterest(position.TdInterestFee); + position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum); + position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum); + position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum); + position.FloatRate = RoundEodInterest(position.FloatRate); + position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue); + position.TdCloseInterest = RoundMoney(position.TdCloseInterest); + position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee); + position.RealizedInterest = RoundMoney(position.RealizedInterest); + position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee); + } + else + { + // 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。 + position.TdPosiDividend = RoundMoney(position.TdPosiDividend); + position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL); + position.PosiDividendSum = RoundMoney(position.PosiDividendSum); + position.PosiFeePending = RoundMoney(position.PosiFeePending); + position.PosiProfitSum = RoundMoney(position.PosiProfitSum); + position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl); + position.TdCloseDividend = RoundMoney(position.TdCloseDividend); + position.TdCloseFee = RoundMoney(position.TdCloseFee); + position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL); + position.RealizedDividend = RoundMoney(position.RealizedDividend); + position.RealizedFee = RoundMoney(position.RealizedFee); + position.SwapPositionValue = RoundMoney(position.SwapPositionValue); + } + position.RealizedPnl = RoundMoney(position.RealizedPnl); + } + #region 可测试化接缝(Seams)——override 这些虚方法可在测试中替换 DB/外部调用,生产代码行为不变 /// 持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表) protected virtual void PersistEodSwapPosition(eod_swap_position position) { + // 所有新增或更新的日终持仓都经过此入口,避免不同日终分支出现精度差异。 + NormalizeEodPositionForStorage(position); var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode); position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero); @@ -1017,8 +1078,25 @@ namespace YLErp.Modules.SwapModule intersetAcmount /= tradeExtend.AnnualDays; } newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息 - newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest; //上一天待实现 + 当天产生的利息 - flowEvents的利息 - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; + var interestIncomeBeforeSettlement = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome; + var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee; + var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date + && flowEvents.Any() + && RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) + && RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee); + + if (isMaturityFinalSettlement) + { + // 到期日不是清零的充分条件。只有当前事件按金额两位覆盖本腿全部可结金额, + // 才能确认是最终结算;否则保留尾差,避免手工互换少结时永久丢失待实现。 + newEodPayPosition.InterestIncomeSum = 0; + newEodPayPosition.InterestFeeSum = 0; + } + else + { + newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); + newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); + } newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum; @@ -1119,8 +1197,17 @@ namespace YLErp.Modules.SwapModule orginPv = eodPayPosition.InterestPrincipalFix; } var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, true, grossPrice, orginPv, true); - decimal InterestAmount = interests.Sum(x => x.InterestAmount); - decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount); + decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); + decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount); + + // 自动互换的流水和客户资金都由 InterestClosePnL 汇总。先把实际结算收敛到两位, + // 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。 + interests.ForEach(x => + { + x.InterestAmount = RoundMoney(x.InterestAmount); + x.InterestClosePnL = RoundMoney(x.InterestClosePnL); + }); + decimal settledInterestAmount = interests.Sum(x => x.InterestAmount); newEodPayPosition.ValueDate = valueDate; newEodPayPosition.PositionId = position.id; @@ -1146,13 +1233,19 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.TdInterestRate = interval.Rate; //当日已实现 //newEodPayPosition.TdInterestFee = 0; - newEodPayPosition.TdCloseInterest = InterestAmount; + newEodPayPosition.TdCloseInterest = settledInterestAmount; // newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee; //持仓内容-利息腿-损益统计(本方视角) - newEodPayPosition.TdInterestIncome = TdInterestAmount; + newEodPayPosition.TdInterestIncome = tdInterestAmount; Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee},TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); - newEodPayPosition.InterestIncomeSum = 0; - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; + var isMaturityFinalAutoSettlement = valueDate.Date >= td.ExerciseDate.Value.Date; + // 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。 + newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement + ? 0 + : RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); + newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement + ? 0 + : RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum; @@ -1194,12 +1287,26 @@ namespace YLErp.Modules.SwapModule ratio = -ratio; } // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 + // 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum + // 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。 var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; + var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m; + var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m; + var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m; eodPayPosition = new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; eodPayPosition.SwapTradeId = td.id; + // CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义。 + eodPayPosition.PositionId = position.id; eodPayPosition.PosiStartDate = td.StartDate.Value; eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; + eodPayPosition.InterestIncomeSum = lastInterestIncomeSum; + eodPayPosition.InterestFeeSum = lastInterestFeeSum; + // 盘中计息结果 InterestAmount 只承接利息待实现;费用仍由 InterestFeeSum + // 独立滚存,避免后续汇总 InterestProfitSum 时把历史费用重复计算。 + eodPayPosition.InterestProfitSum = lastInterestIncomeSum; + eodPayPosition.RealizedInterest = lastRealizedInterest; + eodPayPosition.RealizedInterestFee = lastRealizedInterestFee; if (newEodPayPosition == null) { newEodPayPosition = eodPayPosition.Clone(); @@ -1277,14 +1384,16 @@ namespace YLErp.Modules.SwapModule $",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); if (closePercent == 1) { + // 全量平仓后不应把待实现利息或费用带入下一交易日。 newEodPayPosition.InterestIncomeSum = 0; + newEodPayPosition.InterestFeeSum = 0; } else { newEodPayPosition.InterestIncomeSum = InterestAmount; + newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; } //持仓内容-利息腿-损益统计(本方视角) - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum; diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs index 2b28cca0..c986aa8a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs @@ -460,7 +460,7 @@ namespace YLErp.Modules.SwapModule flow_Event.InterestDirection = position.InterestDirection; flow_Event.InterestRate = position.InterestRateDefault; - flow_Event.InterestPrincipal = position.InterestPrincipalFix; + flow_Event.InterestPrincipal = Math.Round(position.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); flow_Event.InterestSwapInterval = position.InterestSwapInterval; flow_Event.InterestMode = position.InterestMode; flow_Event.UnderlyingInstrumentType = position.UnderlyingInstrumentType; diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index f31bec68..430a5e53 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -372,18 +372,18 @@ namespace YLErp.Modules.SwapModule exportModel.PositionType = item.PositionType==1?"多头":"空头"; exportModel.UnderlyingCode = item.UnderlyingCode; exportModel.MatuirityDate = item.MatuirityDate.OtcFormatDate(); - exportModel.TradingAmountAvg = item.TradingAmountAvg.OtcFormatMoney(false, 2); - exportModel.TradingAmountFeeAvg = item.TradingAmountFeeAvg.OtcFormatMoney(false, 2); - exportModel.Quantity = item.Quantity.OtcFormatMoney(false, 2); - exportModel.TradingAmount = item.TradingAmount.OtcFormatMoney(false, 2); + exportModel.TradingAmountAvg = item.TradingAmountAvg.OtcFormat(OtcFormatFlag.umprice); + exportModel.TradingAmountFeeAvg = item.TradingAmountFeeAvg.OtcFormat(OtcFormatFlag.umprice); + exportModel.Quantity = item.Quantity.OtcFormatMoney(false, 4); + exportModel.TradingAmount = item.TradingAmount.OtcFormatMoney(false, 4); exportModel.ContractSize = item.ContractSize.ToString(); - exportModel.TradingFee = item.TradingFee.OtcFormatMoney(false, 2); - exportModel.TradingFeePending = item.TradingFeePending.OtcFormatMoney(false, 2); - exportModel.DividendPending = item.DividendPending.OtcFormatMoney(false, 2); - exportModel.MarkClosePnl = item.MarkClosePnl.OtcFormatMoney(false, 2); - exportModel.DividendIn = item.DividendIn.OtcFormatMoney(false, 2); + exportModel.TradingFee = item.TradingFee.OtcFormatMoney(false, 4); + exportModel.TradingFeePending = item.TradingFeePending.OtcFormatMoney(false, 4); + exportModel.DividendPending = item.DividendPending.OtcFormatMoney(false, 4); + exportModel.MarkClosePnl = item.MarkClosePnl.OtcFormatMoney(false, 4); + exportModel.DividendIn = item.DividendIn.OtcFormatMoney(false, 4); exportModel.OptLog = item.OptLog; - exportModel.InitYtm = item.InitYtm?.OtcFormatMoney(false, 9); + exportModel.InitYtm = item.InitYtm; list.Add(exportModel); } var tplFilePath = OtcAppContext.MapPath("/App_Docs"); diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index f32f97fe..caae51b4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -32,6 +32,7 @@ using YLErp.Modules.RiskModule; using YLErp.Modules.SalesModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; +using YLErp.Modules.TradeModule.DocGenerateModule; using YLErp.Modules.TradeModule.QueryModule; using YLErp.Modules.UnderlyingModule; using YLErp.QdpModule; @@ -52,16 +53,6 @@ namespace YLErp.Modules.SwapModule } - private static decimal ValidateDeliveryPrice(decimal price, string fieldName) - { - var roundedPrice = Math.Round(price, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero); - if (price != roundedPrice) - { - throw new ServiceException($"{fieldName}最多保留{ConsGlobal.SwapDeliveryPriceRound}位小数"); - } - return roundedPrice; - } - private static decimal? RoundSwapBondNetPriceAndYtm(decimal? value) { return value.HasValue @@ -271,55 +262,67 @@ namespace YLErp.Modules.SwapModule string structureType = "普通债券类收益互换", bool cashNeedAfter = false) { - int SwapEndDays = UnderlyingHelper.GetApplicableMarginRate(client.id,underlying.UnderlyingCode,flowMerge.OccurTime)?.swap_days??14; - var td = PrepareTrade(flowMerge, client, asset, underlying, SwapEndDays, structureType); - PrepareTradeExtend(flowMerge, td, underlying, swapFloatRate); - td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); - flowMerge.SwapTradeNo = td.TradeNumber; - PrepareSwapTrade(td, TradeSourceEnum.系统交易, underlying); - DbContext.trade.Add(td); - DbContext.SaveChanges(); - td.trade_Initial_Margin = new trade_initial_margin() + trade td; + using (var trans = BeginTransaction()) { - TradeId = td.id, - MarginType = 1, - Direction = (int)SwapDirectionEnum.收取, - MarginValue = 0, - }; - AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.MainProtocolCode, client.MainProtocolCode); - AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.SupProtocolCode, client.SupProtocolCode); - AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.TradingPlace, "柜台市场"); - if (!string.IsNullOrEmpty(clearingAgency)) - { - AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.ClearingAgency, clearingAgency); + int SwapEndDays = UnderlyingHelper.GetApplicableMarginRate(client.id,underlying.UnderlyingCode,flowMerge.OccurTime)?.swap_days??14; + td = PrepareTrade(flowMerge, client, asset, underlying, SwapEndDays, structureType); + PrepareTradeExtend(flowMerge, td, underlying, swapFloatRate); + if (!GuolianContractNoGenerator.IsGuolianSwapTrade(td)) + { + td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); + } + PrepareSwapTrade(td, TradeSourceEnum.系统交易, underlying); + DbContext.trade.Add(td); + DbContext.SaveChanges(); + if (GuolianContractNoGenerator.TryGenerateTradeNumberAfterSave(DbContext, td)) + { + DbContext.SaveChanges(); + } + flowMerge.SwapTradeNo = td.TradeNumber; + td.trade_Initial_Margin = new trade_initial_margin() + { + TradeId = td.id, + MarginType = 1, + Direction = (int)SwapDirectionEnum.收取, + MarginValue = 0, + }; + AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.MainProtocolCode, client.MainProtocolCode); + AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.SupProtocolCode, client.SupProtocolCode); + AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.TradingPlace, "柜台市场"); + if (!string.IsNullOrEmpty(clearingAgency)) + { + AddTradeMeta(false, td.id, YLErp.DBModels.Consts.ConsTradeMetaKey.ClearingAgency, clearingAgency); + } + TradeExtendJson tradeExtendJson = new TradeExtendJson() + { + FlowBookMode = (int)FlowBookModeEnum.先进先出, + FloatingPnlAnnualized = false, + NeedOpenFee = true, + OpenFeeType = 1, + Direction = 1, + InterestCalcMode = "10", + SettlementRules = cashNeedAfter ? 1 : 0, + }; + if (structureType != "普通债券类收益互换") + { + tradeExtendJson.FlowBookMode = (int)FlowBookModeEnum.加权平均; + } + td.trade_extend = new trade_extend() + { + TradeId = td.id, + ExtendJson = JsonHelper.Serialize(tradeExtendJson) + }; + DbContext.trade_initial_margin.Add(td.trade_Initial_Margin); + DbContext.trade_extend.Add(td.trade_extend); + foreach (var item in td.swap_positions) + { + item.SwapTradeId = td.id; + DbContext.swap_position.Add(item); + } + DbContext.SaveChanges(); + trans.Commit(); } - TradeExtendJson tradeExtendJson = new TradeExtendJson() - { - FlowBookMode = (int)FlowBookModeEnum.先进先出, - FloatingPnlAnnualized = false, - NeedOpenFee = true, - OpenFeeType = 1, - Direction = 1, - InterestCalcMode = "10", - SettlementRules = cashNeedAfter ? 1 : 0, - }; - if (structureType != "普通债券类收益互换") - { - tradeExtendJson.FlowBookMode = (int)FlowBookModeEnum.加权平均; - } - td.trade_extend = new trade_extend() - { - TradeId = td.id, - ExtendJson = JsonHelper.Serialize(tradeExtendJson) - }; - DbContext.trade_initial_margin.Add(td.trade_Initial_Margin); - DbContext.trade_extend.Add(td.trade_extend); - foreach (var item in td.swap_positions) - { - item.SwapTradeId = td.id; - DbContext.swap_position.Add(item); - } - DbContext.SaveChanges(); new TradeConfirmService(UserInfo).SwapTradeConfirm(td, "流水自动簿记确认交易", true, flowMerge.OccurTime, "流水自动"); return td; } @@ -581,7 +584,9 @@ namespace YLErp.Modules.SwapModule dbTrade.CheckTradeUpdate = Convert.ToInt32(TradeCheckEnum.StatusOfOld); dbTrade.CheckStatus = null; //如果是新增待确认并且修改了客户,需要重新生成交易编号 - if (oldClientId != dbTrade.ClientId && canGenerateTradeNumber) + if (oldClientId != dbTrade.ClientId + && canGenerateTradeNumber + && !GuolianContractNoGenerator.IsGuolianSwapTrade(dbTrade)) { dbTrade.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(dbTrade, DbContext); } @@ -602,7 +607,7 @@ namespace YLErp.Modules.SwapModule dbTrade.trade_extend = req.trade_extend; dbTrade.swap_positions = req.swap_positions; dbTrade.MetaDic = req.MetaDic; - dbTrade.InitYtm = RoundSwapBondNetPriceAndYtm(req.swap_positions.FirstOrDefault(p => p.InitYtm != null)?.InitYtm); + dbTrade.InitYtm = req.swap_positions.FirstOrDefault(p => p.InitYtm != null)?.InitYtm; InnerSaveTrade(false, dbTrade, changsStr, changeConfirmStatus); return dbTrade; @@ -614,6 +619,10 @@ namespace YLErp.Modules.SwapModule { //保存修改 DbContext.SaveChanges(); + if (isAddNew && GuolianContractNoGenerator.TryGenerateTradeNumberAfterSave(DbContext, dbTrade)) + { + DbContext.SaveChanges(); + } SaveTradeExend(dbTrade); SaveTradeMargin(dbTrade); SaveSwapPositions(dbTrade.swap_positions, dbTrade); @@ -638,8 +647,15 @@ namespace YLErp.Modules.SwapModule } if (changeConfirmStatus) { - //删除交易确认书 TODO - new TradeDocumentDataService(this).DeleteBooksAfterEditTrade(dbTrade); + if (GuolianSwapConfirmBookScope.IsPreApprovalTrade(dbTrade)) + { + new GuolianSwapConfirmBookCleanupService(this).InvalidateAfterEdit(dbTrade); + } + else + { + //删除交易确认书 TODO + new TradeDocumentDataService(this).DeleteBooksAfterEditTrade(dbTrade); + } } int eventType = isAddNew ? (int)SwapEventTypeEnum.新增交易 : (int)SwapEventTypeEnum.修改交易; new SwapEventService(UserInfo).AddSwapEventDate(dbTrade.TradeDate.Value, dbTrade.id, eventType, "", 0, true, "", true); @@ -710,7 +726,10 @@ namespace YLErp.Modules.SwapModule PrepareUnderlying(req, um); } - req.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(req, DbContext); + if (!GuolianContractNoGenerator.IsGuolianSwapTrade(req)) + { + req.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(req, DbContext); + } } else if (DbContext.trade.Any(n => n.TradeNumber == req.TradeNumber)) { @@ -1381,16 +1400,10 @@ namespace YLErp.Modules.SwapModule position.UnderlyingCode = swap.UnderlyingCode; position.UnderlyingInstrumentType = swap.UnderlyingInstrumentType; position.PosiDirection = swap.PosiDirection; - // position.PosiGrossPrice = string.IsNullOrEmpty(swap.UnderlyingCode) - // ? Math.Round(swap.PosiGrossPrice, ConsGlobal.SwapDeliveryPriceRound, MidpointRounding.AwayFromZero) - // : ValidateDeliveryPrice(swap.PosiGrossPrice, "期初交割价"); - var storagePriceRound = ConsGlobal.InstrumentType.IsBond(swap.UnderlyingInstrumentType) - ? ConsGlobal.PriceRound - : ConsGlobal.SwapDeliveryPriceRound; - position.PosiGrossPrice = Math.Round(swap.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero); + position.PosiGrossPrice = swap.PosiGrossPrice; position.PosiNetPrice = swap.PosiQuantity == 0 ? 0 : (position.PosiGrossPrice + (position.PosiTradingFeePending / swap.PosiQuantity) * ratio); position.PosiNetPrice = Math.Round(position.PosiNetPrice, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); - position.PosiNetNoFeePrice = RoundSwapBondNetPriceAndYtm(swap.PosiNetNoFeePrice); + position.PosiNetNoFeePrice = swap.PosiNetNoFeePrice; position.PosiNetFeePrice = swap.PosiQuantity == 0 ? 0 : (swap.PosiNetNoFeePrice + (position.PosiTradingFeePending / swap.PosiQuantity) * ratio); position.PosiNetFeePrice = Math.Round(position.PosiNetFeePrice??0, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); position.PosiNotionalValue = Math.Round(swap.PosiNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -1398,7 +1411,7 @@ namespace YLErp.Modules.SwapModule position.InterestDirection = swap.InterestDirection; position.InterestMode = swap.InterestMode; position.InterestRateDefault = swap.InterestRateDefault; - position.InterestPrincipalFix = swap.InterestPrincipalFix; + position.InterestPrincipalFix = Math.Round(swap.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); position.InterestSwapInterval = swap.InterestSwapInterval; position.PosiStartDate = td.StartDate.Value; position.PosiMatuirityDate = td.ExerciseDate.Value; @@ -1416,7 +1429,7 @@ namespace YLErp.Modules.SwapModule position.interest_rest_days = swap.interest_rest_days; position.interest_rule = swap.interest_rule; position.category_tag = string.IsNullOrEmpty(swap.category_tag) ? "互换利率" : swap.category_tag; - position.InitYtm = RoundSwapBondNetPriceAndYtm(swap.InitYtm); + position.InitYtm = swap.InitYtm; if (position.InitYtm != null && position.InitYtm > 0) { td.InitYtm = position.InitYtm; @@ -1672,7 +1685,7 @@ namespace YLErp.Modules.SwapModule { posi.InterestAmount = eodPosi.RealizedInterest; posi.InterestFeePending = eodPosi.InterestFeePending; - posi.InterestPrincipalFix= eodPosi.InterestPrincipalFix; + posi.InterestPrincipalFix = Math.Round(eodPosi.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); posi.PosiTradingFeePending = eodPosi.PosiFeePending; posi.PosiDividendIncome = eodPosi.PosiDividendSum; posi.PosiQuantity = eodPosi.PosiQuantity; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeContractGenerateService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeContractGenerateService.cs index 877ef0c3..316633e5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeContractGenerateService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeContractGenerateService.cs @@ -226,9 +226,10 @@ namespace YLErp.Modules.TradeModule.DealModule return true; } - // 过滤掉待确认和审批中的交易,这些状态不允许生成确认书 + // 仅国联收益互换允许在审批前生成确认书,其他交易保持原状态限制。 var invalidStatusList = new List { ConsTrade.新增待确认, ConsTrade.修改待确认, ConsTrade.审批中 }; - var invalidTrades = trades.Where(t => invalidStatusList.Contains(t.TradeStatus)).ToList(); + var invalidTrades = trades.Where(t => invalidStatusList.Contains(t.TradeStatus) + && !GuolianSwapConfirmBookScope.IsPreApprovalTrade(t)).ToList(); if (invalidTrades.Any()) { error.AddRange(invalidTrades.Select(t => $"交易 {t.TradeNumber} 状态为【{t.TradeStatus}】,不能生成确认书")); @@ -237,6 +238,20 @@ namespace YLErp.Modules.TradeModule.DealModule logger.Info($"[GJGenerateConfirmBook] 过滤掉 {invalidTrades.Count} 笔待确认/审批中交易"); } + var preApprovalTrades = trades.Where(GuolianSwapConfirmBookScope.IsPreApprovalTrade).ToList(); + foreach (var trade in preApprovalTrades) + { + var validationErrors = new GuolianSwapConfirmBookValidationService(this).Validate(trade); + if (!validationErrors.Any()) + { + continue; + } + + error.Add($"交易 {trade.TradeNumber} 无法生成确认书:{string.Join(";", validationErrors)}"); + tradeIds = tradeIds.Where(id => id != trade.id).ToList(); + trades = trades.Where(item => item.id != trade.id).ToList(); + } + if (!trades.Any()) { logger.Info("[GJGenerateConfirmBook] 过滤后无有效交易可生成确认书"); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 8bfd988e..0ec40dad 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -7,6 +7,7 @@ using YLErp.Model.Enum; using YLErp.Modules.ClientModule; using YLErp.Modules.SwapModule; using YLErp.Modules.TradeDalModule; +using YLErp.Modules.TradeModule.DocGenerateModule; namespace YLErp.Modules.TradeModule.DealModule { @@ -295,6 +296,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// private TradeOpenResult TradeReject(TradeOpenReqModel req, trade td) { + var isGuolianPreApprovalTrade = GuolianSwapConfirmBookScope.IsPreApprovalTrade(td); var oldOrderId = td.ProcessOrderId; req.comments = FormatCommentsWithRole(oldOrderId, req.comments); td.ProcessOrderId = Convert.ToInt32(PStatusEnum.reject); @@ -367,6 +369,11 @@ namespace YLErp.Modules.TradeModule.DealModule var tcDetails = DbContext.trade_cash_detail.Where(O => O.TradeCashId == tcId); DbContext.trade_cash_detail.RemoveRange(tcDetails); + if (isGuolianPreApprovalTrade) + { + new GuolianSwapConfirmBookCleanupService(this).CleanRejectedPreApprovalTrade(td); + } + DbContext.SaveChanges(); return new TradeOpenResult(td) { RetCode = TradeOpenRetCode.Success }; diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/BatchConfirmationGenerateService.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/BatchConfirmationGenerateService.cs index db8136e4..4ee21d4c 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/BatchConfirmationGenerateService.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/BatchConfirmationGenerateService.cs @@ -75,7 +75,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule throw new ServiceException("没有已确认的记录信息!"); } var tradeDic = allTrades.Where(O => - statusList.Contains(O.TradeStatus) + (statusList.Contains(O.TradeStatus) || GuolianSwapConfirmBookScope.IsPreApprovalTrade(O)) && O.ClientId != 0 && O.ParentTradeId == 0) .ToDictionary(n => n.id, n => new trade_contract_group { trade = n }); diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs index 050fa800..2ac4c1ac 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs @@ -1356,11 +1356,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule { return UniqueTimeId.GetStr(); } - if (Trade.TradeType == "收益互换") - { - return GuolianContractNoGenerator.Generate(DbContext, (trade)Trade, _client.Code); - } - return Trade.TradeNumber; + return Trade.TradeNumber; } /// @@ -2881,4 +2877,4 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule return DbContext.swap_event.FirstOrDefault(x => x.id == eventId); } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateService.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateService.cs index e2d4a4df..c0e72bd0 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateService.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateService.cs @@ -667,7 +667,9 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule public (bool, string, DateTime, string) UploadContractFile(string encryptId, string fileDescription, string ContractCode, bool OurpartySeal, bool CounterpartySeal, UploadFileModel uploadFileModel) { - + var contractCodeName = PS.Config.Company == YLErp.Configuration.CompanyEnum.国联 + ? "交易确认书编号" + : "合约编号"; try { var msg = ""; @@ -707,7 +709,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule { if (db.trade_contract_r.Where(O => O.ContractCode == ContractCode && O.IsValid).Any()) { - return (false, "合约编号已存在", DateTime.Now, ""); + return (false, $"{contractCodeName}已存在", DateTime.Now, ""); } else { @@ -854,6 +856,10 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule UploadFileModel uploadFileModel) { var nowDate = DateTime.Now; + var isGuolian = PS.Config.Company == YLErp.Configuration.CompanyEnum.国联; + var contractCodeName = isGuolian + ? "交易确认书编号" + : "合约编号"; if (tradeId <= 0) { return new UploadContractFileNewResult { Success = false, Message = "请传入参数", OptDate = nowDate, DocumentPath = string.Empty }; @@ -887,7 +893,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule activeCode = (contractCode ?? "").Trim(); if (string.IsNullOrWhiteSpace(activeCode)) { - return new UploadContractFileNewResult { Success = false, Message = "合约编号必填", OptDate = nowDate, DocumentPath = string.Empty }; + return new UploadContractFileNewResult { Success = false, Message = $"{contractCodeName}必填", OptDate = nowDate, DocumentPath = string.Empty }; } if (!string.Equals(oldCode, activeCode, StringComparison.OrdinalIgnoreCase)) @@ -895,7 +901,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule var isExists = new TradeContractGenerateService(OptUser).GetExistsContractCode(new[] { activeCode }).Any(); if (isExists) { - return new UploadContractFileNewResult { Success = false, Message = "合约编号已存在", OptDate = nowDate, DocumentPath = string.Empty }; + return new UploadContractFileNewResult { Success = false, Message = $"{contractCodeName}已存在", OptDate = nowDate, DocumentPath = string.Empty }; } } } @@ -907,18 +913,25 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule } else { - var client = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId); - if (string.IsNullOrEmpty(client?.Code)) + if (isGuolian) { - return new UploadContractFileNewResult { Success = false, Message = "对手方代码缩写缺失,请联系运营组同事维护", OptDate = nowDate, DocumentPath = string.Empty }; + activeCode = trade.TradeNumber; + } + else + { + var client = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId); + if (string.IsNullOrEmpty(client?.Code)) + { + return new UploadContractFileNewResult { Success = false, Message = "对手方代码缩写缺失,请联系运营组同事维护", OptDate = nowDate, DocumentPath = string.Empty }; + } + activeCode = GenerateContractCodeForTrade(db, trade, client?.Code); } - activeCode = GenerateContractCodeForTrade(db, trade, client?.Code); } } if (string.IsNullOrWhiteSpace(activeCode)) { - return new UploadContractFileNewResult { Success = false, Message = "合约编号生成失败", OptDate = nowDate, DocumentPath = string.Empty }; + return new UploadContractFileNewResult { Success = false, Message = $"{contractCodeName}生成失败", OptDate = nowDate, DocumentPath = string.Empty }; } trade_contract_document oldDoc = null; @@ -1006,19 +1019,13 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule return new UploadContractFileNewResult { Success = false, Message = "操作出错", OptDate = nowDate, DocumentPath = string.Empty }; } } - /// - /// 生成合约编号逻辑:如果是收益互换交易,使用国联的生成规则;否则默认使用 TradeNumber 作为合约编号。 - /// - /// - /// - /// - /// - private string GenerateContractCodeForTrade(YLContext db, trade trade,string clientCode) + private static string GenerateContractCodeForTrade(YLContext db, trade trade, string clientCode) { if (trade.TradeType == "收益互换") { - return GuolianContractNoGenerator.Generate(db, trade, clientCode ?? ""); + return GuolianContractNoGenerator.Generate(db, trade, clientCode ?? string.Empty); } + return trade.TradeNumber; } diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs index 67901f31..b1d666a0 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs @@ -1,4 +1,5 @@ using YLErp.BLL; +using YLErp.Configuration; using YLErp.DBModels; using YLErp.DBModels.Consts; using YLErp.DBModels.Enums; @@ -17,6 +18,69 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule { private static readonly object _syncLock = new object(); + public static bool IsGuolianSwapTrade(trade trade) + { + return IsGuolianSwapTrade(trade, PS.Config.Company); + } + + public static bool IsGuolianSwapTrade(trade trade, CompanyEnum company) + { + return company == CompanyEnum.国联 + && trade?.TradeType == "收益互换"; + } + + public static bool ShouldGenerateTradeNumberAfterSave(trade trade, CompanyEnum company) + { + return IsGuolianSwapTrade(trade, company) + && string.IsNullOrWhiteSpace(trade.TradeNumber); + } + + public static bool CanReuseRejectedTradeNumber(trade trade, CompanyEnum company) + { + return IsGuolianSwapTrade(trade, company); + } + + /// + /// 交易首次入库后按国联交易确认书编号规则回写交易编号。 + /// + /// 是否生成了交易编号 + public static bool TryGenerateTradeNumberAfterSave(YLContext dbContext, trade trade) + { + if (!IsGuolianSwapTrade(trade) || !string.IsNullOrWhiteSpace(trade.TradeNumber)) + { + return false; + } + + if (trade.id <= 0) + { + throw new InvalidOperationException("国联收益互换交易编号必须在交易入库后生成"); + } + + var clientCode = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId)?.Code; + if (string.IsNullOrWhiteSpace(clientCode)) + { + throw new ServiceException("对手方代码缩写缺失,请联系运营组同事维护"); + } + + trade.TradeNumber = Generate(dbContext, trade, clientCode); + return true; + } + + /// + /// 按国联确认书编号规则拼装编号(纯函数,不依赖数据库,便于单测)。 + /// 格式:GLMS-{clientCode}-{成交日期(yyyyMMdd)}-FICC-{序号}-{标的代码(去点)} + /// - 对客交易(甲方):序号用两位数字,从 01 开始 + /// - 非对客交易(乙方):序号用字母递增,从 A 开始 + /// + public static string BuildTradeNumber(trade trade, string clientCode, int sequenceNo) + { + var isClientTrade = trade.OpponentRole == "甲方"; + var underlyingCode = (trade.UnderlyingCode ?? "").Replace(".", ""); // 去掉标的代码中的点号 + var tradeDateStr = (trade.TradeDate ?? DateTime.MinValue).ToString("yyyyMMdd"); + var seq = isClientTrade ? sequenceNo.ToString("D2") : NumberToLetter(sequenceNo); + return $"GLMS-{clientCode}-{tradeDateStr}-FICC-{seq}-{underlyingCode}"; + } + /// /// 生成国贸交易确认书编号 /// @@ -29,12 +93,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule trade trade, string clientCode) { - // 根据trade的OpponentRole判断是否为对客交易(甲方=对客)。glms客户规定甲方=对客 - var isClientTrade = trade.OpponentRole == "甲方"; - var underlyingCode = (trade.UnderlyingCode ?? "").Replace(".", ""); // 去掉标的代码中的点号 var tradeDate = trade.TradeDate ?? DateTime.MinValue; - var tradeDateStr = tradeDate.ToString("yyyyMMdd"); - var prefix = $"GLMS-{clientCode}-{tradeDateStr}-FICC-"; lock (_syncLock) { @@ -59,14 +118,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule && t.id < trade.id); var sequenceNo = sameDayTradesCount + 1; - if (isClientTrade) - { - return $"{prefix}{sequenceNo:D2}-{underlyingCode}"; - } - else - { - return $"{prefix}{NumberToLetter(sequenceNo)}-{underlyingCode}"; - } + return BuildTradeNumber(trade, clientCode, sequenceNo); } } diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookValidationService.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookValidationService.cs new file mode 100644 index 00000000..c23410a2 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianSwapConfirmBookValidationService.cs @@ -0,0 +1,192 @@ +using System.Linq.Expressions; +using YLErp.Configuration; +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Model; + +namespace YLErp.Modules.TradeModule.DocGenerateModule +{ + /// + /// 国联收益互换审批前生成确认书的范围与资料校验。 + /// + public static class GuolianSwapConfirmBookScope + { + private static readonly HashSet PreApprovalStatuses = new() + { + ConsTrade.新增待确认, + ConsTrade.修改待确认, + ConsTrade.审批中 + }; + + public static bool IsPreApprovalTrade(trade trade) + { + return IsPreApprovalTrade(trade, PS.Config.Company); + } + + public static bool IsPreApprovalTrade(trade trade, CompanyEnum company) + { + return IsGuolianSwap(trade, company) + && trade.ValidState != ConsGlobal.InValid + && IsPreApprovalStatus(trade.TradeStatus); + } + + public static bool IsGuolianSwap(trade trade) + { + return IsGuolianSwap(trade, PS.Config.Company); + } + + public static bool IsGuolianSwap(trade trade, CompanyEnum company) + { + return company == CompanyEnum.国联 && trade.TradeType == "收益互换"; + } + + public static bool IsPreApprovalStatus(string tradeStatus) + { + return PreApprovalStatuses.Contains(tradeStatus); + } + + } + + /// + /// 清理国联收益互换审批前生成的确认书,不复用按合同编号批量清理的通用逻辑。 + /// + public class GuolianSwapConfirmBookCleanupService : YLBaseService + { + public GuolianSwapConfirmBookCleanupService(YLBaseService baseService) : base(baseService) + { + } + + public void InvalidateAfterEdit(trade trade) + { + if (!GuolianSwapConfirmBookScope.IsGuolianSwap(trade) + || !GuolianSwapConfirmBookScope.IsPreApprovalStatus(trade.TradeStatus)) + { + return; + } + + InvalidateRelations(trade.id); + } + + public void CleanRejectedPreApprovalTrade(trade trade) + { + if (!GuolianSwapConfirmBookScope.IsGuolianSwap(trade)) + { + return; + } + + GlobalConfig.SealHandler.RemoveQueue(trade.id); + InvalidateOaResults(trade.id); + var contractCodes = InvalidateRelations(trade.id); + foreach (var contractCode in contractCodes) + { + var hasActiveReference = DbContext.trade_contract_r.Any( + BuildOtherActiveReferencePredicate(contractCode, trade.id)); + if (!hasActiveReference) + { + var documents = DbContext.trade_contract_document.Where(x => x.Code == contractCode + && x.Type == ContractTypeEnum.Trade); + DbContext.trade_contract_document.RemoveRange(documents); + } + } + } + + public static Expression> BuildOtherActiveReferencePredicate( + string contractCode, + int rejectedTradeId) + { + return relation => relation.ContractCode == contractCode + && relation.Type == ContractTypeEnum.Trade + && relation.IsValid + && relation.TradeId != rejectedTradeId; + } + + public static Expression> BuildTradeConfirmationRelationPredicate( + int tradeId) + { + return relation => relation.TradeId == tradeId + && relation.Type == ContractTypeEnum.Trade; + } + + public static Expression> BuildActiveOaResultPredicate( + int tradeId) + { + return result => result.trade_id == tradeId && result.is_valid; + } + + private void InvalidateOaResults(int tradeId) + { + var oaResults = DbContext.tradeContractOaResult + .Where(BuildActiveOaResultPredicate(tradeId)) + .ToList(); + oaResults.ForEach(x => x.is_valid = false); + } + + private List InvalidateRelations(int tradeId) + { + var relations = DbContext.trade_contract_r + .Where(BuildTradeConfirmationRelationPredicate(tradeId)) + .ToList(); + relations.Where(x => x.IsValid).ToList().ForEach(x => x.IsValid = false); + return relations.Select(x => x.ContractCode).Distinct().ToList(); + } + } + + /// + /// 仅校验模板无法正常生成或选择时必需的交易数据。 + /// + public class GuolianSwapConfirmBookValidationService : YLBaseService + { + public GuolianSwapConfirmBookValidationService(YLBaseService baseService) : base(baseService) + { + } + + public List Validate(trade trade) + { + var errors = new List(); + var client = DataCacheProvider.GetClientDataSource().GetData(trade.ClientId); + var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(trade.UnderlyingCode); + + if (client == null) + { + errors.Add("客户信息不存在"); + return errors; + } + + if (underlying == null) + { + errors.Add("标的信息不存在"); + } + + ValidateInitialPosition(trade, errors); + + return errors; + } + + private void ValidateInitialPosition(trade trade, List errors) + { + var position = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == trade.id + && x.IsInitial + && !x.Invalid + && (x.PositionType == (int)PositionTypeFlag.Long || x.PositionType == (int)PositionTypeFlag.Short)); + + if (position == null) + { + errors.Add("缺少初始标的持仓"); + return; + } + + if (position.PosiDirection != (int)SwapDirectionEnum.支付 + && position.PosiDirection != (int)SwapDirectionEnum.收取) + { + errors.Add("初始标的持仓收支方向无效"); + } + + if (string.IsNullOrWhiteSpace(position.UnderlyingCode)) + { + errors.Add("初始标的持仓缺少标的代码"); + } + } + + } +} diff --git a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs index fb8d835d..e5688c3c 100644 --- a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs +++ b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeFlowImportService.cs @@ -11,6 +11,7 @@ using YLErp.Enums; using YLErp.Model.Enum; using YLErp.Modules.CalculationModule; using YLErp.Modules.EodModule; +using YLErp.Modules.TradeModule.DocGenerateModule; using YLErp.Modules.TradeModule.OrderModule; namespace YLErp.Modules.TradeModule.SwapModule @@ -362,7 +363,10 @@ namespace YLErp.Modules.TradeModule.SwapModule importTrade.TradeStatus = ConsTrade.确认成交; - importTrade.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(importTrade, DbContext); + if (!GuolianContractNoGenerator.IsGuolianSwapTrade(importTrade)) + { + importTrade.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(importTrade, DbContext); + } var currencyRate = new EodCurrencyRateService(UserInfo).GetCurrencyRate(importTrade.QuoteCurrency, importTrade.SettlementCurrency, importTrade.TradeDate.Value, seekPreday: importTrade.TradeDate.Value == valuedateBLL.ValueDate); var tradePriceQuote = 0.0; @@ -388,6 +392,10 @@ namespace YLErp.Modules.TradeModule.SwapModule SetDBModelCreator(importTrade); DbContext.trade.Add(importTrade); DbContext.SaveChanges(); + if (GuolianContractNoGenerator.TryGenerateTradeNumberAfterSave(DbContext, importTrade)) + { + DbContext.SaveChanges(); + } importTrade.trade_swap.GetTradePrice = (importTrade.trade_swap.GetSingleFee ?? 0) * (importTrade.Lots ?? 0) + (importTrade.trade_swap.GetUnAnnualRate ?? 0) * importTrade.StockEqvNotional; importTrade.trade_swap.GetMarginRate = marginRate; importTrade.trade_swap.TradeId = importTrade.id; diff --git a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs index 6f985cbf..cec26a82 100644 --- a/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs +++ b/YLErpDAL/Modules/TradeModule/SwapModule/SwapTradeImportService.cs @@ -11,6 +11,7 @@ using YLErp.Enums; using YLErp.Model.Enum; using YLErp.Modules.CalculationModule; using YLErp.Modules.EodModule; +using YLErp.Modules.TradeModule.DocGenerateModule; using YLErp.Modules.TradeModule.OrderModule; using YLErp.QdpModule; @@ -679,7 +680,10 @@ namespace YLErp.Modules.TradeModule.SwapModule } if (string.IsNullOrWhiteSpace(td.TradeNumber)) { - td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); + if (!GuolianContractNoGenerator.IsGuolianSwapTrade(td)) + { + td.TradeNumber = BizLogicSingleton.Instance.GenerateTradeNumberBeforeConfirm(td, DbContext); + } } else { @@ -736,6 +740,10 @@ namespace YLErp.Modules.TradeModule.SwapModule SetDBModelCreator(td); DbContext.trade.Add(td); DbContext.SaveChanges(); + if (GuolianContractNoGenerator.TryGenerateTradeNumberAfterSave(DbContext, td)) + { + DbContext.SaveChanges(); + } td.trade_swap.TradeId = td.id; td.trade_swap.SwapType = "普通"; td.trade_swap.OptId = UserId; diff --git a/YLErpWeb/App_Data/Config/swappriceprecision.js b/YLErpWeb/App_Data/Config/swappriceprecision.js new file mode 100644 index 00000000..b76e12d5 --- /dev/null +++ b/YLErpWeb/App_Data/Config/swappriceprecision.js @@ -0,0 +1,46 @@ +// 通过 /front/swappriceprecision 加载。可按 UnderlyingInstrumentType 修改;缺项或非法值由页面精度组件回退内置默认规则。 +window.main = window.main || {}; +window.main.swapPricePrecision = { + Stock: { integerDigits: 7, precision: 2 }, + StockIndex: { integerDigits: 7, precision: 2 }, + StockIF: { integerDigits: 7, precision: 4 }, + CommodityFutures: { integerDigits: 7, precision: 4 }, + CommoditySpot: { integerDigits: 7, precision: 4 }, + NewOtcStock: { integerDigits: 7, precision: 4 }, + HKStock: { integerDigits: 7, precision: 4 }, + HKStockIndex: { integerDigits: 7, precision: 4 }, + Fund: { integerDigits: 7, precision: 4 }, + Bond: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + TBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + CreditBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + OtherBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + TBFutures: { integerDigits: 8, precision: 4 }, + OtherFutures: { integerDigits: 8, precision: 4 }, + GoldSpot: { integerDigits: 8, precision: 4 }, + OtherSpot: { integerDigits: 8, precision: 4 }, + AbroadFutures: { integerDigits: 8, precision: 4 }, + AbroadSpot: { integerDigits: 8, precision: 4 }, + AbroadStock: { integerDigits: 8, precision: 2 }, + AbroadStockIndex: { integerDigits: 8, precision: 4 }, + ExRate: { integerDigits: 2, precision: 8 }, + Shibor: { integerDigits: 2, precision: 4 }, + FixingRepoRate: { integerDigits: 2, precision: 4 }, + + // TODO: 利率收益率(6+8)、债券指数(6+4)、黄金期货(6+4)待对应的 UnderlyingInstrumentType 枚举确认后启用。 +}; diff --git a/YLErpWeb/Common/HtmlUtil.cs b/YLErpWeb/Common/HtmlUtil.cs index cc3e6247..12b6ed9c 100644 --- a/YLErpWeb/Common/HtmlUtil.cs +++ b/YLErpWeb/Common/HtmlUtil.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Html; -using System.Collections; +using System.IO; +using System.Reflection; using YLErp.Events; namespace YLErp @@ -17,15 +18,54 @@ namespace YLErp //bin目录文件版本 public static readonly DateTime BinFileVersion; + /// + /// Git提交哈希(取自程序集 AssemblyInformationalVersion,由.NET SDK在编译时自动生成, + /// 格式 "1.0.0+<sha>";前端诊断信息用它精确定位是哪次提交的部署)。 + /// + public static readonly string GitCommit; + static long _dataCacheUpdateTime; static HtmlUtil() { - //获取bin目录中YLErp开头的文件最后修改日期作为JS和CSS文件版本 + //获取bin目录中YLErp开头的文件最后修改日期作为后端构建版本 var binDir = new DirectoryInfo(AppContext.BaseDirectory); var files = binDir.GetFiles("YLErp*"); BinFileVersion = files.Any() ? files.Max(n => n.LastWriteTime) : DateTime.MinValue; - JsVersion = BinFileVersion.ToString("yyMMddHHmmss"); + + //JsVersion(浏览器 ?v= 缓存戳)必须同时反映【后端 DLL】和【前端脚本】的变更: + //历史上只取 DLL mtime,导致纯前端改动(改 wwwroot/Scripts 而不重编 DLL)时 + //缓存戳不变、浏览器仍加载旧版——即"发布了但不更新"(见 ?otcdebug=1 排查时的症状)。 + //现纳入 wwwroot/Scripts(业务脚本)+ wwwroot/Statics/bundles(打包产物)的 mtime 取 max。 + //wwwroot 定位用 Directory.GetCurrentDirectory()(与 StaticUrlMiddleware 一致, + //开发时=项目根、部署时=publish 目录),目录不存在则回退到仅 DLL mtime。 + var versionClock = BinFileVersion; + var webRoot = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); + foreach (var sub in new[] { "Scripts", Path.Combine("Statics", "bundles") }) + { + var dir = Path.Combine(webRoot, sub); + if (Directory.Exists(dir)) + { + foreach (var f in new DirectoryInfo(dir).GetFiles("*", SearchOption.AllDirectories)) + { + if (f.LastWriteTime > versionClock) versionClock = f.LastWriteTime; + } + } + } + JsVersion = versionClock.ToString("yyMMddHHmmss"); + + //从 AssemblyInformationalVersion 读取 git sha(SDK 编译时已嵌入,零额外依赖) + var infoVer = typeof(HtmlUtil).Assembly + .GetCustomAttribute()?.InformationalVersion; + //格式 "1.0.0+",取 + 之后部分;无 +(如固定版本号 1.0.0)则回退读 .git/HEAD + if (!string.IsNullOrEmpty(infoVer) && infoVer.Contains('+')) + { + GitCommit = infoVer.Substring(infoVer.LastIndexOf('+') + 1); + } + else + { + GitCommit = ReadGitCommitFromRepo() ?? (infoVer ?? "unknown"); + } EventBus.Subscribe(t => { @@ -33,6 +73,51 @@ namespace YLErp }); } + /// + /// 从 .git/HEAD 读取当前 commit 短 sha,作为 AssemblyInformationalVersion 无 sha 时的回退。 + /// 找不到 .git 或读取失败返回 null(不影响启动)。 + /// + static string ReadGitCommitFromRepo() + { + try + { + //从 ContentRoot 向上找 .git 目录(开发时在项目根/仓库根) + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + var gitDir = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(gitDir)) + { + return ParseGitHead(gitDir); + } + dir = dir.Parent; + } + } + catch { /* 读取失败不影响启动,返回 null */ } + return null; + } + + static string ParseGitHead(string gitDir) + { + var headFile = Path.Combine(gitDir, "HEAD"); + if (!File.Exists(headFile)) return null; + var head = File.ReadAllText(headFile).Trim(); + //HEAD 格式:"ref: refs/heads/xxx" 或 detached 时的直接 sha + if (head.StartsWith("ref: ")) + { + var refPath = Path.Combine(gitDir, head.Substring(5).Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(refPath)) + { + return File.ReadAllText(refPath).Trim().Substring(0, 12); + } + } + else if (head.Length >= 12) + { + return head.Substring(0, 12); + } + return null; + } + /// /// 获取基础数据JS连接 /// diff --git a/YLErpWeb/Controllers/FrontController.cs b/YLErpWeb/Controllers/FrontController.cs index 3f5925e8..66234ef1 100644 --- a/YLErpWeb/Controllers/FrontController.cs +++ b/YLErpWeb/Controllers/FrontController.cs @@ -37,6 +37,17 @@ namespace YLErp.Web.Controllers return Content(js, "text/javascript"); } + [AllowAnonymous] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public ActionResult SwapPricePrecision() + { + var filePath = Server.MapPath("~/App_Data/Config/swappriceprecision.js"); + var js = System.IO.File.Exists(filePath) + ? System.IO.File.ReadAllText(filePath) + : "var main = main || {}; main.swapPricePrecision = {};"; + return Content(js, "text/javascript"); + } + //今年及前后两年的日历数据(缓存120s) [ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any)] public ActionResult Calendar() diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 00297abc..b0115d2f 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -186,7 +186,6 @@ namespace YLErp.Web.Controllers renewPosition.InterestAmount = 0; renewPosition.InterestFeePending = 0; renewPosition.PosiDividendIncome = 0; - renewPosition.PosiTradingFeePending = 0; renewPosition.InterestSwapInterval = null; renewPosition.Obervation = null; return renewPosition; diff --git a/YLErpWeb/Views/Shared/_MainLayout.cshtml b/YLErpWeb/Views/Shared/_MainLayout.cshtml index 07b5b054..59716859 100644 --- a/YLErpWeb/Views/Shared/_MainLayout.cshtml +++ b/YLErpWeb/Views/Shared/_MainLayout.cshtml @@ -157,6 +157,18 @@ + + } @@ -49,4 +51,4 @@ @Html.Raw(JqGridSimple.OutTable()) - \ No newline at end of file + diff --git a/YLErpWeb/Views/SwapTrade2/EventList.cshtml b/YLErpWeb/Views/SwapTrade2/EventList.cshtml index c3905e38..cfbe53fc 100644 --- a/YLErpWeb/Views/SwapTrade2/EventList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/EventList.cshtml @@ -12,7 +12,7 @@ - + }
@@ -28,4 +28,4 @@
-
+ \ No newline at end of file diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index c86b8774..0e702cde 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -4,6 +4,7 @@ Layout = "~/Views/Shared/_InfoLayout.cshtml"; bool isUseApproval = ViewBag.isUseApproval; bool isShowReCheckClose = ViewBag.IsShowReCheckClose; + bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode; } @section CSS{ @@ -28,6 +29,8 @@ + + } @@ -138,7 +141,10 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @@ -152,7 +158,10 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @@ -163,7 +172,7 @@
收支方向收支方向多空方向 标的代码 期初标的交割全价%浮动端平仓盈亏
{{floatPosition.PayDirection==1?"收取":"支付"}}{{floatPosition.PayDirection==1?"收取":"支付"}} {{floatPosition.PositionType==1?"多头":"空头"}} {{priceFormat(floatPosition.TradingAmountNetAvg > 0 ? floatPosition.TradingAmountNetAvg : floatPosition.PosiNetPrice)}} - + diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index e1ecadcb..184177a4 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -4,6 +4,7 @@ Layout = "~/Views/Shared/_InfoLayout.cshtml"; bool isUseApproval = ViewBag.isUseApproval; bool isShowReCheckClose = ViewBag.IsShowReCheckClose; + bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode; } @section CSS{ @@ -22,6 +23,8 @@ + + } @@ -177,7 +180,10 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @@ -191,7 +197,10 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @@ -200,7 +209,7 @@
收支方向收支方向多空方向 标的代码 期初标的交割全价%浮动端平仓盈亏
{{floatPosition.PayDirection==1?"收取":"支付"}}{{floatPosition.PayDirection==1?"收取":"支付"}} {{floatPosition.PositionType==1?"多头":"空头"}} {{priceFormat(floatPosition.PosiGrossPrice)}} - + diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 2e707b9c..bf22e849 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -80,7 +80,8 @@ tradingPlaceMap = places, clearingAgencyMap = agencys, timeUnits = GlobalData.AllTimeUnits(), - otcFormatConfig = otcFormatConfig + otcFormatConfig = otcFormatConfig, + swapFloatingIncomeReceiveOnlyMode = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode }; //pageObj.clearingAgencyMap.Remove("甲方"); //pageObj.clearingAgencyMap.Remove("已方"); @@ -100,6 +101,8 @@ + + } @@ -442,7 +445,7 @@ - + @@ -454,7 +457,7 @@ - @@ -512,7 +518,7 @@
-
+
收支方向收支方向 多空方向 标的代码 期初标的交割全价%交易费用后付
+ - + + AUTOREV - + + AUTOREV - 重算 + + AUTOREV - + {{item.underlying!=null?item.underlying.QuoteUnitString:''}} @@ -499,7 +505,7 @@ - +
我方{{item.PosiDirection==1?"支付":"收取"}}交易费用
diff --git a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml index 4cdf1c8e..cde7fed6 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeView.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeView.cshtml @@ -1,4 +1,5 @@ @using YLErp.Enums; +@using System.Globalization; @model TradeViewModel @{ @@ -29,6 +30,8 @@ var positions = trade.swap_positions.Where(x=>x.PosiDirection>0&&x.IsInitial).ToList(); var realPositions = trade.swap_positions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList(); var sr = trade.trade_extend.ExtendObj.SettlementRules; + bool hideFloatingIncomeDirection = PS.Config.ErpElement.SwapFloatingIncomeReceiveOnlyMode; + string SwapPriceData(decimal? value) => value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; } @section CSS{ @@ -46,6 +49,8 @@ + + } @@ -361,10 +366,13 @@
-
-
-
-
+ @if (singleTrade || !hideFloatingIncomeDirection) + { +
+
+
+
+ } @if (trade.swap_positions != null) { var paySwapPositions = trade.swap_positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial&&x.PosiStartDate==trade.StartDate).ToList(); @@ -374,7 +382,10 @@
- + @if (!hideFloatingIncomeDirection) + { + + } @@ -392,19 +403,22 @@ var bgclass = item.PosiDirection == (int)SwapDirectionEnum.收取 ? "swapget" : "swappay"; var multiplier = ConsGlobal.InstrumentType.IsBond(item.UnderlyingInstrumentType) ? 100 : 1; - + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) { }
收支方向收支方向多空方向 标的代码 @spotPriceTitle
@((SwapDirectionEnum)item.PosiDirection)@((SwapDirectionEnum)item.PosiDirection)@(item.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头") @item.UnderlyingCode - @((item.PosiGrossPrice * multiplier).OtcFormat(OtcFormatFlag.umprice)) + - @(((item.PosiNetNoFeePrice ?? 0) * multiplier).OtcFormat(OtcFormatFlag.umprice)) + - @((item.InitYtm * multiplier).OtcFormat(OtcFormatFlag.umprice)) + @@ -417,7 +431,7 @@ } else { - @item.PosiTradingFeeUnit.ToString("0.00") + @item.PosiTradingFeeUnit.ToString("0.000000") } @@ -431,7 +445,7 @@ } - else + else if (!hideFloatingIncomeDirection) {
@@ -573,10 +587,13 @@
-
-
-
-
+ @if (singleTrade || realPositions.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode)) || !hideFloatingIncomeDirection) + { +
+
+
+
+ } @if (trade.swap_positions != null) { var paySwapPositions = trade.swap_positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).ToList(); @@ -586,7 +603,10 @@
- + @if (!hideFloatingIncomeDirection) + { + + } @@ -605,20 +625,23 @@ var bgclass = item.PosiDirection == 1 ? "swapget" : "swappay"; var multiplier = ConsGlobal.InstrumentType.IsBond(item.UnderlyingInstrumentType) ? 100 : 1; - + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) { }
收支方向收支方向多空方向 标的代码 @spotPriceTitle
- @((SwapDirectionEnum)item.PosiDirection) - + @((SwapDirectionEnum)item.PosiDirection) + @(item.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头") @item.UnderlyingCode - @((item.PosiGrossPrice * multiplier).OtcFormat(OtcFormatFlag.umprice)) + - @(((item.PosiNetNoFeePrice ?? 0) * multiplier).OtcFormat(OtcFormatFlag.umprice)) + @@ -635,7 +658,7 @@
} - else + else if (!hideFloatingIncomeDirection) {
@@ -793,7 +816,10 @@
- + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) @@ -813,18 +839,21 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) { - - + + } else { - - + + } @@ -989,7 +1018,10 @@
收支方向收支方向多空方向 标的代码浮动端平仓盈亏
@((SwapDirectionEnum)closeFloat.PayDirection)@((SwapDirectionEnum)closeFloat.PayDirection)@(closeFloat.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头") @closeFloat.UnderlyingCode@((closeFloat.PosiGrossPrice * multiplier).OtcFormat(OtcFormatFlag.umprice))@((closeFloat.TradingAmountAvg * multiplier).OtcFormat(OtcFormatFlag.umprice))@(closeFloat.PosiGrossPrice.OtcFormat(OtcFormatFlag.umprice))@(closeFloat.TradingAmountAvg.OtcFormat(OtcFormatFlag.umprice))@(closeFloat.Quantity.OtcFormat(OtcFormatFlag.StockEqvNotional)) @(closeFloat.TradingFee.OtcFormat(OtcFormatFlag.StockEqvNotional))
- + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) @@ -1009,19 +1041,22 @@ - + @if (!hideFloatingIncomeDirection) + { + + } @if (isBond) { - - + + } else { - + } - + diff --git a/YLErpWeb/Views/TradeConfirmBook/Index.cshtml b/YLErpWeb/Views/TradeConfirmBook/Index.cshtml index 8c5a0b65..91a7b06e 100644 --- a/YLErpWeb/Views/TradeConfirmBook/Index.cshtml +++ b/YLErpWeb/Views/TradeConfirmBook/Index.cshtml @@ -10,6 +10,7 @@ isRuiDa = YLErp.PS.Config.Company == YLErp.Configuration.CompanyEnum.瑞达, isBoHai = YLErp.PS.Config.Company == YLErp.Configuration.CompanyEnum.渤海, isWuChan = YLErp.PS.Config.Company == YLErp.Configuration.CompanyEnum.物产中大, + isGuoLian = YLErp.PS.Config.Company == YLErp.Configuration.CompanyEnum.国联, ConfirmBookStampStatus = PS.Config.ErpElement.ConfirmBookStampStatus, valuedate = valuedateBLL.ValueDate.ToString("yyyy-MM-dd"), configcolumn = configcolumn_data.交易确认书, @@ -56,7 +57,7 @@
@Html.ShortInput("TradeNumber", "交易编号") - @Html.ShortInput("ContractCode", "合约编号") + @Html.ShortInput("ContractCode", pageObj.isGuoLian ? "交易确认书编号" : "合约编号") @Html.MyAceDropdownInput("ClientId", "客户名称", ClientDataModel.GetAllClient()) @Html.MyAceDropdownInput("AssetId", "簿记账户", AssetunitController.GetClientassetunit()) @Html.MyAceDropdownInput("GroupId", "簿记账户组", AssetUnitModel.GetAllAssetUnitGroupItem()) diff --git a/YLErpWeb/Views/TradeConfirmBook/confirmBookUploadNew.cshtml b/YLErpWeb/Views/TradeConfirmBook/confirmBookUploadNew.cshtml index 5f022100..f90d886c 100644 --- a/YLErpWeb/Views/TradeConfirmBook/confirmBookUploadNew.cshtml +++ b/YLErpWeb/Views/TradeConfirmBook/confirmBookUploadNew.cshtml @@ -1,10 +1,14 @@ @{ ViewBag.Title = "confirmBookUploadNew"; Layout = "~/Views/Shared/_InfoLayout.cshtml"; + var contractCodeName = YLErp.PS.Config.Company == YLErp.Configuration.CompanyEnum.国联 + ? "交易确认书编号" + : "合约编号"; var pageObj = new { - TradeId = ViewBag.TradeId + TradeId = ViewBag.TradeId, + ContractCodeName = contractCodeName }; } @@ -38,7 +42,7 @@ var contractCode = $("#contractCode").val(); if (useContractCode && main.isEmpty(contractCode)) { - main.message("合约编号必填"); + main.message(page.ContractCodeName + "必填"); return; } @@ -97,8 +101,8 @@
- - + +
diff --git a/YLErpWeb/Views/entryexit/entryexitList.cshtml b/YLErpWeb/Views/entryexit/entryexitList.cshtml index ca20dcc2..aaafc651 100644 --- a/YLErpWeb/Views/entryexit/entryexitList.cshtml +++ b/YLErpWeb/Views/entryexit/entryexitList.cshtml @@ -355,7 +355,7 @@ return numeral(-1 * cellValue).format("0,0.00") === 'NaN' ? "0" : numeral(-1 * cellValue).format("0,0.00"); } else { - return numeral(cellValue).format("0,0.00") === 'NaN' ? "0" : numeral(cellValue).format("0,0.00"); + return numeral(cellValue).format("0,0.000") === 'NaN' ? "0" : numeral(cellValue).format("0,0.000"); } } diff --git a/YLErpWeb/YLErpWeb.csproj b/YLErpWeb/YLErpWeb.csproj index b50c342f..3a096f2e 100644 --- a/YLErpWeb/YLErpWeb.csproj +++ b/YLErpWeb/YLErpWeb.csproj @@ -232,6 +232,37 @@ + + + + <_RebuildScript>$(MSBuildProjectDirectory)\rebuild-bundles.ps1 + <_RebuildScriptSh>$(MSBuildProjectDirectory)/rebuild-bundles.sh + + + + + + + + + <_RebuildScript>$(MSBuildProjectDirectory)\rebuild-bundles.ps1 + <_RebuildScriptSh>$(MSBuildProjectDirectory)/rebuild-bundles.sh + + + + + diff --git a/YLErpWeb/bundleconfig.json b/YLErpWeb/bundleconfig.json index b825adda..b67eb0cd 100644 --- a/YLErpWeb/bundleconfig.json +++ b/YLErpWeb/bundleconfig.json @@ -1,4 +1,4 @@ -// Configure bundling and minification for the project. +// Configure bundling and minification for the project. // More info at https://go.microsoft.com/fwlink/?LinkId=808241 [ { @@ -13,7 +13,7 @@ "wwwroot/Statics/libs/jquery/jquery.floatingscroll.min.js", "wwwroot/Statics/libs/jquery/jquery.validate.min.js", "wwwroot/Statics/libs/jquery/jquery.validate.unobtrusive.min.js", - "wwwroot/Scripts/Myjs.js" + "wwwroot/Scripts/MyJs.js" ], "minify": { //是否压缩 @@ -30,7 +30,7 @@ "wwwroot/Statics/libs/base/lodash.min.js", "wwwroot/Statics/libs/datetime/dayjs/dayjs.min.js", "wwwroot/Statics/libs/datetime/dayjs/plugin/weekday.js", - "wwwroot/Statics/libs/datetime/dayjs/plugin/isoweek.js", + "wwwroot/Statics/libs/datetime/dayjs/plugin/isoWeek.js", "wwwroot/Statics/libs/datetime/dayjs/dayjs.plugin.js", "wwwroot/Statics/libs/bootstrap/js/bootstrap.bundle.min.js", "wwwroot/Statics/libs/JqGrid4/js/i18n/grid.locale-cn.js", diff --git a/YLErpWeb/fe-tests/bondCalc.integration.test.js b/YLErpWeb/fe-tests/bondCalc.integration.test.js new file mode 100644 index 00000000..b1a88e9f --- /dev/null +++ b/YLErpWeb/fe-tests/bondCalc.integration.test.js @@ -0,0 +1,849 @@ +/** + * bondCalc.integration.test.js — 债券三字段互算集成层测试 + * ============================================================================ + * 目的:覆盖 swapCalc.js 纯函数与宿主框架(Vue/jQuery/main.post)之间的"胶水代码", + * 守卫纯函数测试(bondCalc.test.js)无法触及的集成层断裂点。 + * + * 覆盖的根因(见 项目文档/互换债券三字段互算踩坑总结与测试指南.md): + * 根因1:vue-number-input .native 修饰符被 jQuery 拦截 → keydown 事件不触发 + * 根因2:Vue 2 非 data() 属性不可响应 → 直接赋值不触发视图更新 + * 根因3:$set 设置相同对象引用不触发更新 → 需创建新对象引用 + * 根因4:main.post 业务错误走 reject → .done() 不执行失败分支 + * + * 运行:cd YLErpWeb/fe-tests && npx jest bondCalc.integration + */ + +const { JSDOM } = require('jsdom'); +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; +global.navigator = dom.window.navigator; + +// 桥接 FastVue 全局 +Object.defineProperty(global, 'FastVue', { + get() { return global.window.FastVue; }, + set(value) { global.window.FastVue = value; }, + configurable: true +}); + +const $ = require('jquery'); +global.$ = $; +global.jQuery = $; + +// main mock(含 formatNumber + post 的 Deferred 模拟) +global.main = { + formatNumber: function (number, precision, options) { + const n = Number(number); + if (Number.isNaN(n)) return '0'; + const useGrouping = options && options.grouping; + if (useGrouping) { + return n.toLocaleString('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }); + } + return n.toFixed(precision); + }, + message: jest.fn(), + post: null // 各测试块自行设置 +}; + +const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); + +// ============================================================================ +// 根因 1:vue-number-input keydown 事件 emit 链路 +// ============================================================================ +// 背景:vue-number-input 在 mounted() 中用 jQuery $(_el).on('keydown', __keyHandle) +// 重新绑定了 keydown。.native 修饰符依赖 Vue 的根元素事件代理, +// 但 jQuery 绑定后原生事件不再冒泡到 Vue 的事件代理层, +// 导致 v-on:keydown.native 收不到事件。 +// 修复:在 mounted() 内部 this.$el.addEventListener('keydown', fn) + self.$emit('keydown', e), +// 绕过 jQuery 事件系统直接 emit。 +// 此测试验证修复后的 emit 链路是否正常。 +describe('根因1:vue-number-input keydown 事件 emit 链路', () => { + + beforeEach(() => { + jest.resetModules(); + // 重新加载 fastVue.base.js 获取最新组件定义 + delete global.window.FastVue; + // 需要重新 require 以触发 IIFE 注册 FastVue + require('../wwwroot/Scripts/fast/fastVue.base.js'); + }); + + /** + * 模拟 vueNumberInput 组件的 mounted() 钩子行为。 + * 由于 jest 环境没有完整 Vue 运行时,这里手动执行 mounted 中的关键逻辑: + * addEventListener('keydown') + $emit('keydown') + */ + function mountComponent(el) { + const emitted = []; + const self = { + $el: el, + $emit(event, payload) { emitted.push({ event, payload }); }, + onchange: function () {}, + format: { precision: 2, percent: false, append: '' } + }; + + // 复刻 mounted() 中的 keydown 监听(修复后的代码) + el.addEventListener('keydown', function (e) { + self.$emit('keydown', e); + }); + + return { self, emitted }; + } + + test('按键(数字2)时组件应 $emit("keydown") —— 验证修复后事件链路正常', () => { + const el = document.createElement('input'); + document.body.appendChild(el); + const { emitted } = mountComponent(el); + + const keydown = new window.KeyboardEvent('keydown', { keyCode: 50, bubbles: true }); + el.dispatchEvent(keydown); + + expect(emitted.length).toBeGreaterThanOrEqual(1); + expect(emitted[0].event).toBe('keydown'); + }); + + test('回车键(keyCode=13)时也 $emit("keydown") —— 回车除外逻辑在父组件而非组件内部', () => { + const el = document.createElement('input'); + document.body.appendChild(el); + const { emitted } = mountComponent(el); + + const keydown = new window.KeyboardEvent('keydown', { keyCode: 13, bubbles: true }); + el.dispatchEvent(keydown); + + // 组件内部不区分键码,一律 emit;回车除外逻辑由父组件 onBondPriceKeydown 处理 + expect(emitted.length).toBeGreaterThanOrEqual(1); + expect(emitted[0].event).toBe('keydown'); + }); + + test('修饰键(Ctrl+V)也 $emit("keydown") —— 修饰键除外逻辑在父组件', () => { + const el = document.createElement('input'); + document.body.appendChild(el); + const { emitted } = mountComponent(el); + + const keydown = new window.KeyboardEvent('keydown', { + keyCode: 86, ctrlKey: true, bubbles: true + }); + el.dispatchEvent(keydown); + + expect(emitted.length).toBeGreaterThanOrEqual(1); + }); + + test('模拟 .native 修饰符失效场景:jQuery 重绑后原生 addEventListener 仍能收到事件', () => { + const el = document.createElement('input'); + document.body.appendChild(el); + + // 模拟 jQuery $(_el).on('keydown', __keyHandle) 重绑 keydown + // 这会覆盖 Vue .native 的事件代理,但不影响直接 addEventListener + $(el).on('keydown', function () { /* jQuery handler */ }); + + const { emitted } = mountComponent(el); + + // 即使 jQuery 重绑了 keydown,addEventListener 仍能收到事件 + const keydown = new window.KeyboardEvent('keydown', { keyCode: 50, bubbles: true }); + el.dispatchEvent(keydown); + + expect(emitted.length).toBeGreaterThanOrEqual(1); + expect(emitted[0].event).toBe('keydown'); + }); +}); + +// ============================================================================ +// 根因 2+3:Vue 2 响应式——$set 新引用 vs 旧引用 vs 直接赋值 +// ============================================================================ +// 背景:swapCalc.js 的纯函数直接对 state 赋值(state.bondDriverType = null 等), +// 但 bondDriverType/bondAuto/bondRev 不在 Vue data() 中声明,是非响应式属性。 +// Vue 2 基于 Object.defineProperty,只能追踪初始化时已存在的属性。 +// 直接赋值不触发视图更新;$set 可注册响应式,但传入相同引用时 Vue 跳过更新。 +// 修复:syncBondFlags 每次创建全新对象引用 + $forceUpdate 兜底。 +describe('根因2+3:Vue 2 响应式——$set 新引用 vs 旧引用 vs 直接赋值', () => { + + /** + * 模拟 Vue 2 的响应式系统行为。 + * Vue 2 用 Object.defineProperty,只能追踪已存在的属性。 + * $set 可以添加新响应式属性,但对相同引用的对象会跳过更新。 + */ + function createReactiveMock() { + // trackKeys: 已被 $set 注册为响应式的 (target, key) 对 + const tracked = new WeakMap(); // target -> Set of keys + const updateLog = []; + + function isTracked(target, key) { + const keys = tracked.get(target); + return !!(keys && keys.has(key)); + } + function markTracked(target, key) { + if (!tracked.has(target)) tracked.set(target, new Set()); + tracked.get(target).add(key); + } + + const vm = { + $set(target, key, value) { + const oldVal = target[key]; + const alreadyTracked = isTracked(target, key); + + // Vue 2 $set 的核心行为: + // 1. 如果属性尚未被注册为响应式 → 注册 + 赋值 + 触发更新(即使值相同) + // 2. 如果属性已是响应式 → 按相同引用/值跳过优化 + if (alreadyTracked && oldVal === value) { + updateLog.push({ key, skipped: true, reason: 'same_val' }); + return; + } + + if (!alreadyTracked) { + markTracked(target, key); + } + target[key] = value; + updateLog.push({ key, skipped: false, oldVal: oldVal, newVal: value }); + }, + $forceUpdate() { + updateLog.push({ key: '__forceUpdate', skipped: false }); + }, + getUpdateLog() { return updateLog; }, + isReactive(target, key) { + // 支持两种调用方式:isReactive(key) 或 isReactive(target, key) + if (arguments.length >= 2) return isTracked(target, key); + // 兼容旧签名:遍历 tracked 查找(仅用于无 target 的场景) + return false; + } + }; + + return vm; + } + + function makeBondItem() { + return { + PosiNetNoFeePrice: 0.995, + PosiGrossPrice: 1.0, + InitYtm: 0.026, + isBond: true, + UnderlyingCode: '190000.IB', + // bondDriverType/bondAuto/bondRev 初始不存在(模拟后端数据无这些字段) + }; + } + + test('根因2:直接赋值 bondDriverType 不注册响应式 → 视图不更新', () => { + const item = makeBondItem(); + const vm = createReactiveMock(); + + // 模拟 swapCalc 纯函数直接赋值(无 $set) + SwapCalc.applyBondManualEdit(item, 'CP'); + + // item 上有了属性,但 Vue 不知道它是响应式的 + expect(item.bondDriverType).toBeNull(); + expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false }); + // 未通过 $set 注册 → 不响应式 + expect(vm.isReactive(item, 'bondDriverType')).toBe(false); + }); + + test('根因2修复:$set 注册 bondDriverType 为响应式', () => { + const item = makeBondItem(); + const vm = createReactiveMock(); + + SwapCalc.applyBondManualEdit(item, 'CP'); + + // applyBondManualEdit 已直接赋值 bondDriverType=null(非响应式) + expect(item.bondDriverType).toBeNull(); + + // 模拟 syncBondFlags 的 $set 调用:首次 $set 注册为响应式 + vm.$set(item, 'bondDriverType', item.bondDriverType === undefined ? null : item.bondDriverType); + + expect(vm.isReactive(item, 'bondDriverType')).toBe(true); + const log = vm.getUpdateLog(); + expect(log[0].skipped).toBe(false); // 首次注册,即使值相同也触发 + }); + + test('根因3:$set 传入相同引用 → Vue 跳过更新(bondAuto 对象)', () => { + const item = makeBondItem(); + // 先初始化 bondAuto + item.bondAuto = { CP: false, DP: true, YD: true }; + const vm = createReactiveMock(); + // 第一次 $set 注册 + vm.$set(item, 'bondAuto', item.bondAuto); + + // 纯函数原地修改 bondAuto(创建新对象) + SwapCalc.applyBondManualEdit(item, 'CP'); + // 现在 item.bondAuto 是新对象 { CP: true, DP: false, YD: false } + + // 如果错误地传入相同引用(模拟旧版 syncBondFlags bug) + const sameRef = item.bondAuto; + vm.$set(item, 'bondAuto', sameRef); + + const log = vm.getUpdateLog(); + const lastSet = log[log.length - 1]; + expect(lastSet.skipped).toBe(true); // 相同引用 → 跳过 + expect(lastSet.reason).toBe('same_val'); + }); + + test('根因3修复:$set 传入新对象引用 → Vue 触发更新', () => { + const item = makeBondItem(); + item.bondAuto = { CP: false, DP: true, YD: true }; + const vm = createReactiveMock(); + // 第一次 $set 注册为响应式 + vm.$set(item, 'bondAuto', item.bondAuto); + + // 纯函数原地修改(applyBondManualEdit 创建新对象赋给 state.bondAuto) + SwapCalc.applyBondManualEdit(item, 'CP'); + // item.bondAuto 现在是新对象 { CP: true, DP: false, YD: false } + const afterEdit = item.bondAuto; + + // 修复后的 syncBondFlags:每次创建全新对象字面量 + const newRef = { CP: !!item.bondAuto.CP, DP: !!item.bondAuto.DP, YD: !!item.bondAuto.YD }; + expect(newRef).not.toBe(afterEdit); // 新引用确实与旧引用不同 + + vm.$set(item, 'bondAuto', newRef); + + const log = vm.getUpdateLog(); + const lastSet = log[log.length - 1]; + expect(lastSet.skipped).toBe(false); // 不同引用 → 触发更新 + }); + + test('完整 syncBondFlags 模拟:$set + $forceUpdate 全链路', () => { + const item = makeBondItem(); + const vm = createReactiveMock(); + + // 模拟 swapCalc.applyBondManualEdit(直接赋值,非响应式) + SwapCalc.applyBondManualEdit(item, 'DP'); + + // 模拟修复后的 syncBondFlags:$set 首次注册(都应触发更新)+ $forceUpdate + vm.$set(item, 'bondDriverType', item.bondDriverType === undefined ? null : item.bondDriverType); + vm.$set(item, 'bondAuto', { CP: !!item.bondAuto.CP, DP: !!item.bondAuto.DP, YD: !!item.bondAuto.YD }); + vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD }); + vm.$forceUpdate(); + + const log = vm.getUpdateLog(); + // 3 个 $set 都是首次注册(属性之前不是响应式)→ 都不跳过 + 1 个 forceUpdate + const sets = log.filter(e => e.key !== '__forceUpdate'); + expect(sets.length).toBe(3); + sets.forEach(s => expect(s.skipped).toBe(false)); + expect(log.some(e => e.key === '__forceUpdate')).toBe(true); + }); + + test('连续两次 applyBondManualEdit + syncBondFlags:第二次也触发更新(新引用)', () => { + const item = makeBondItem(); + const vm = createReactiveMock(); + + // 第一次:CP 标 REV + SwapCalc.applyBondManualEdit(item, 'CP'); + vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD }); + const log1 = vm.getUpdateLog().filter(e => !e.skipped); + + // 第二次:DP 标 REV + SwapCalc.applyBondManualEdit(item, 'DP'); + // applyBondManualEdit 把 state.bondRev 重新赋为新对象 { CP:false, DP:true, YD:false } + // syncBondFlags 再创建新引用 → $set 时新旧引用不同 → 触发更新 + vm.$set(item, 'bondRev', { CP: !!item.bondRev.CP, DP: !!item.bondRev.DP, YD: !!item.bondRev.YD }); + const log2 = vm.getUpdateLog().filter(e => !e.skipped); + + // 第二次也应触发更新(新引用保证) + expect(log2.length).toBeGreaterThan(log1.length); + const lastSet = vm.getUpdateLog()[vm.getUpdateLog().length - 1]; + expect(lastSet.skipped).toBe(false); + }); +}); + +// ============================================================================ +// 根因 4:main.post Promise 链——reject 走 .fail 不走 .done +// ============================================================================ +// 背景:main.post 包装了 jQuery $.ajax,在 resp.success===false(业务错误)时 +// 调用 deferred.reject(resp)。calcBondForItem 只挂了 .done(),reject 时不执行。 +// 修复:补 .fail() 处理 reject 路径。 +describe('根因4:main.post Promise 链——reject 不走 .done', () => { + + /** + * 模拟 main.__post 的 resolve/reject 行为。 + * 返回 jQuery Deferred promise,可链式 .done()/.fail()。 + */ + function mockPost(response, shouldReject) { + const d = $.Deferred(); + if (shouldReject) { + d.reject(response); + } else { + d.resolve(response); + } + return d.promise(); + } + + test('resolve 时 .done 被调用、.fail 不被调用', () => { + const doneFn = jest.fn(); + const failFn = jest.fn(); + + mockPost({ success: true, obj: { cleanPrice: 99 } }, false) + .done(doneFn) + .fail(failFn); + + expect(doneFn).toHaveBeenCalled(); + expect(failFn).not.toHaveBeenCalled(); + }); + + test('reject 时 .fail 被调用、.done 不被调用', () => { + const doneFn = jest.fn(); + const failFn = jest.fn(); + + mockPost({ success: false, msg: '债券不存在' }, true) + .done(doneFn) + .fail(failFn); + + expect(doneFn).not.toHaveBeenCalled(); + expect(failFn).toHaveBeenCalled(); + }); + + test('仅挂 .done(旧 bug):reject 时失败分支静默跳过', () => { + const doneFn = jest.fn(); + const failHandlerCalled = { value: false }; + + // 模拟旧代码:只有 .done,没有 .fail + mockPost({ success: false, msg: '债券不存在' }, true) + .done(function () { + // 这段代码永远不会执行 + failHandlerCalled.value = true; + }); + + expect(doneFn).not.toHaveBeenCalled(); + expect(failHandlerCalled.value).toBe(false); // 失败分支被跳过! + }); + + test('修复后:.done + .fail 都挂 → reject 时 .fail 中的 applyBondCalcFailure 被执行', () => { + const item = { + PosiNetNoFeePrice: 0.995, + PosiGrossPrice: 1.0, + InitYtm: 0.026, + isBond: true, + bondDriverType: 'CP', + bondAuto: { CP: false, DP: true, YD: true }, + bondRev: { CP: false, DP: false, YD: false } + }; + + // 模拟修复后的 calcBondForItem Promise 链 + mockPost({ success: false, msg: '债券不存在' }, true) + .done(function () { + // 成功分支(不会执行) + SwapCalc.applyBondCalcSuccess(item, 'CP'); + }) + .fail(function () { + // 修复:失败分支 + SwapCalc.applyBondCalcFailure(item, 'CP'); + }); + + // 验证失败分支正确执行了 applyBondCalcFailure + expect(item.PosiGrossPrice).toBeNull(); // 非源字段被清空 + expect(item.InitYtm).toBeNull(); // 非源字段被清空 + expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留 + expect(item.bondDriverType).toBeNull(); // 标识全清 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('网络异常也走 reject → .fail 被调用', () => { + const failFn = jest.fn(); + const doneFn = jest.fn(); + + // 网络异常时 jQuery $.ajax().fail(deferred.reject) 也会触发 reject + mockPost(null, true) + .done(doneFn) + .fail(failFn); + + expect(doneFn).not.toHaveBeenCalled(); + expect(failFn).toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// 约定2补充:calcBondForItem 所有失败分支都调 applyBondCalcFailure + syncBondFlags +// ============================================================================ +// 验证 calcBondForItem 的每个退出路径都正确清空了非源字段和标识。 +// 这些测试模拟 calcBondForItem 内部的条件判断,验证纯函数层面的正确性。 +describe('约定2补充:所有失败分支都清空非源字段+清标识', () => { + + function makeBondItem(driver) { + return { + PosiNetNoFeePrice: 0.995, + PosiGrossPrice: 1.0, + InitYtm: 0.026, + isBond: true, + UnderlyingCode: '190000.IB', + bondDriverType: driver, + bondAuto: { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' }, + bondRev: { CP: false, DP: false, YD: false } + }; + } + + test('分支1:源字段为空/非数 → applyBondCalcFailure', () => { + const item = makeBondItem('CP'); + item.PosiNetNoFeePrice = null; // 源字段为空 + + SwapCalc.applyBondCalcFailure(item, 'CP'); + + expect(item.PosiNetNoFeePrice).toBeNull(); // 源字段保留 null + expect(item.PosiGrossPrice).toBeNull(); // 非源清空 + expect(item.InitYtm).toBeNull(); + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('分支1b:源字段为 NaN → applyBondCalcFailure', () => { + const item = makeBondItem('DP'); + item.PosiGrossPrice = NaN; + + SwapCalc.applyBondCalcFailure(item, 'DP'); + + expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空 + // PosiGrossPrice 是 NaN,applyBondCalcFailure 会把它设为 null + expect(item.InitYtm).toBeNull(); + expect(item.bondDriverType).toBeNull(); + }); + + test('分支2:估值日缺失 → applyBondCalcFailure', () => { + const item = makeBondItem('CP'); + + // 模拟 calcBondForItem 中估值日缺失的处理 + const sdMsg = SwapCalc.getBondStartDateMissingMsg(null); + expect(sdMsg).not.toBeNull(); + + SwapCalc.applyBondCalcFailure(item, 'CP'); + + expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留 + expect(item.PosiGrossPrice).toBeNull(); // 非源清空 + expect(item.InitYtm).toBeNull(); + expect(item.bondDriverType).toBeNull(); + }); + + test('分支3:计算器返回业务错误(reject) → applyBondCalcFailure(经 .fail 路径)', () => { + const item = makeBondItem('YD'); + + // 模拟计算器返回业务错误 + const respObj = { errCode: 1, errMsg: '债券不存在', cleanPrice: 0, dirtyPrice: 0, ytm: 0 }; + const err = SwapCalc.getBondCalcErrorMessage(respObj); + expect(err).toBe('债券不存在'); + + // .fail 中调 applyBondCalcFailure + SwapCalc.applyBondCalcFailure(item, 'YD'); + + expect(item.InitYtm).toBe(0.026); // 源字段保留 + expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空 + expect(item.PosiGrossPrice).toBeNull(); // 非源清空 + expect(item.bondDriverType).toBeNull(); + }); + + test('分支4:计算器返回成功但值域异常 → applyBondCalcFailure(经 .done 内拦截)', () => { + const item = makeBondItem('CP'); + + // 模拟计算器返回 errCode=0 但净价为负 + const respObj = { errCode: 0, dirtyPrice: 100, cleanPrice: -117.93, ytm: 6.37 }; + const err = SwapCalc.getBondCalcErrorMessage(respObj); + expect(err).not.toBeNull(); + expect(err).toContain('净价'); + + // .done 内检测到错误 → 调 applyBondCalcFailure + SwapCalc.applyBondCalcFailure(item, 'CP'); + + expect(item.PosiNetNoFeePrice).toBe(0.995); // 源字段保留 + expect(item.PosiGrossPrice).toBeNull(); // 非源清空 + expect(item.InitYtm).toBeNull(); + }); + + test('分支5:计算器无响应(resp.obj 为 null) → applyBondCalcFailure', () => { + const item = makeBondItem('DP'); + + // 模拟 .done 内 !resp || !resp.obj 分支 + const resp = null; + const err = SwapCalc.getBondCalcErrorMessage(resp); + expect(err).toBe('债券计算器无响应,已保留手工输入'); + + SwapCalc.applyBondCalcFailure(item, 'DP'); + + expect(item.PosiGrossPrice).toBe(1.0); // 源字段保留 + expect(item.PosiNetNoFeePrice).toBeNull(); // 非源清空 + expect(item.InitYtm).toBeNull(); + }); + + test('分支6:计算器返回哨兵值(-999999) → applyBondCalcFailure', () => { + const item = makeBondItem('CP'); + + const respObj = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -999999 }; + const err = SwapCalc.getBondCalcErrorMessage(respObj); + expect(err).not.toBeNull(); + expect(err).toContain('哨兵值'); + + SwapCalc.applyBondCalcFailure(item, 'CP'); + + expect(item.PosiNetNoFeePrice).toBe(0.995); + expect(item.PosiGrossPrice).toBeNull(); + expect(item.InitYtm).toBeNull(); + }); +}); + +// ============================================================================ +// 约定3完整状态机:回车→源/AUTO,编辑→REV,回车→源/AUTO(覆盖REV) +// ============================================================================ +// 端到端验证约定1/2/3 的状态机转换顺序: +// 初始(无标识) → 回车CP(源CP+AUTO DP/YD) → 编辑DP(REV DP, 清源/AUTO) → 回车DP(源DP+AUTO CP/YD) +describe('约定1/2/3 状态机:端到端转换', () => { + + function makeBondItem() { + return { + PosiNetNoFeePrice: 0.995, + PosiGrossPrice: 1.0, + InitYtm: 0.026, + bondDriverType: null, + bondAuto: { CP: false, DP: false, YD: false }, + bondRev: { CP: false, DP: false, YD: false } + }; + } + + test('Step1 回车净价(CP) → 源=CP, AUTO={DP,YD}, REV 全清', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + + expect(item.bondDriverType).toBe('CP'); + expect(item.bondAuto).toEqual({ CP: false, DP: true, YD: true }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('Step2 编辑全价(DP)未回车 → REV=DP, 清源/AUTO', () => { + const item = makeBondItem(); + // 先回车 CP + SwapCalc.applyBondCalcSuccess(item, 'CP'); + // 再编辑 DP(未回车) + SwapCalc.applyBondManualEdit(item, 'DP'); + + expect(item.bondDriverType).toBeNull(); // 源被清 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO 被清 + expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false }); // 仅 DP 标 REV + }); + + test('Step3 回车全价(DP) → 源=DP, AUTO={CP,YD}, REV 全清(REV 被覆盖)', () => { + const item = makeBondItem(); + // 回车 CP + SwapCalc.applyBondCalcSuccess(item, 'CP'); + // 编辑 DP(未回车) + SwapCalc.applyBondManualEdit(item, 'DP'); + // 回车 DP + SwapCalc.applyBondCalcSuccess(item, 'DP'); + + expect(item.bondDriverType).toBe('DP'); // 新源 + expect(item.bondAuto).toEqual({ CP: true, DP: false, YD: true }); // CP 降级为 AUTO + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); // REV 被清 + }); + + test('Step4 回车收益率(YD) → 源=YD, AUTO={CP,DP}(最后回车者恒为源)', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + SwapCalc.applyBondCalcSuccess(item, 'DP'); + SwapCalc.applyBondCalcSuccess(item, 'YD'); + + expect(item.bondDriverType).toBe('YD'); + expect(item.bondAuto).toEqual({ CP: true, DP: true, YD: false }); + }); + + test('Step5 计算器失败 → 保留源值、另两清空、标识全清', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + // 模拟计算器失败 + SwapCalc.applyBondCalcFailure(item, 'CP'); + + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + // PosiNetNoFeePrice(源) 保留,另两被清空 + }); + + test('Step6 加载已保存单 → 标识全清(clearBondCalcMarksOnLoad)', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'DP'); + // 模拟重开/刷新 + SwapCalc.clearBondCalcMarksOnLoad(item); + + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + // 价格值保留 + expect(item.PosiNetNoFeePrice).toBe(0.995); + }); + + test('Step7 切换标的 → 标识全清(clearBondCalcFlags)', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + SwapCalc.applyBondManualEdit(item, 'DP'); + // 模拟切换标的 + SwapCalc.clearBondCalcFlags(item); + + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + // ---- 以下为步骤3-b 及多字段组合场景(文档明确列出但此前缺失) ---- + + test('Step3-b 回车CP→改DP→改YD(均不回车) → DP与YD均累计REV(修复后行为)', () => { + const item = makeBondItem(); + // 步骤1:回车净价(CP) + SwapCalc.applyBondCalcSuccess(item, 'CP'); + expect(item.bondDriverType).toBe('CP'); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + + // 步骤2:编辑全价(DP)未回车 → REV={DP:true} + SwapCalc.applyBondManualEdit(item, 'DP'); + expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false }); + + // ★ 步骤3-b:不回车,又改收益率(YD) → DP的REV保留、YD也标REV(累计,不再被覆盖) + SwapCalc.applyBondManualEdit(item, 'YD'); + expect(item.bondDriverType).toBeNull(); // 源已被清 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO 已清 + // 核心断言:applyBondManualEdit 改为累计 REV,多字段连续手动编辑时每个改过的字段都留 REV + expect(item.bondRev).toEqual({ CP: false, DP: true, YD: true }); + // 数值保持不变(约定3:编辑不联动) + expect(item.PosiNetNoFeePrice).toBe(0.995); // 净价保留 + expect(item.PosiGrossPrice).toBe(1.0); // 全价保留 + expect(item.InitYtm).toBe(0.026); // 收益率保留 + }); + + test('Step3-b 变体:回车CP后连续编辑三字段(CP→DP→YD) → 三个字段均累计REV', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + + // 连续编辑三个字段,都不回车 + SwapCalc.applyBondManualEdit(item, 'CP'); // 先改源字段本身 + expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false }); + + SwapCalc.applyBondManualEdit(item, 'DP'); // 再改第二个 → CP的REV保留、DP也标REV + expect(item.bondRev).toEqual({ CP: true, DP: true, YD: false }); + + SwapCalc.applyBondManualEdit(item, 'YD'); // 最后改第三个 → 三者均累计 + expect(item.bondRev).toEqual({ CP: true, DP: true, YD: true }); // 全部累计,不再"最后赢" + }); + + test('Step3-b 三字段轮换编辑(CP→DP→YD→CP) → 三个字段均累计REV', () => { + const item = makeBondItem(); + SwapCalc.applyBondCalcSuccess(item, 'CP'); + + // 四次连续手工编辑,模拟用户在三个输入框之间来回跳转 + SwapCalc.applyBondManualEdit(item, 'CP'); + SwapCalc.applyBondManualEdit(item, 'DP'); + SwapCalc.applyBondManualEdit(item, 'YD'); + SwapCalc.applyBondManualEdit(item, 'CP'); // 回到第一个 + + // 无论轮换多少次,每次编辑的字段都累计REV(不再"只有最后一次留REV") + expect(item.bondRev).toEqual({ CP: true, DP: true, YD: true }); + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('步骤4-1 回车CP→回车DP→改YD(不回车) → 从多源状态降级为单REV(YD)', () => { + const item = makeBondItem(); + // 连续回车两个字段 → 多源状态 + SwapCalc.applyBondCalcSuccess(item, 'CP'); + SwapCalc.applyBondCalcSuccess(item, 'DP'); + expect(item.bondDriverType).toBe('DP'); // 最后回车者为源 + expect(item.bondAuto).toEqual({ CP: true, DP: false, YD: true }); // CP降级AUTO + + // 编辑第三个字段但不回车 → 降级为单字段REV + SwapCalc.applyBondManualEdit(item, 'YD'); + expect(item.bondDriverType).toBeNull(); // 源被清 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); // AUTO全清 + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: true }); // 仅YD留REV + }); + + test('步骤4-1 变体:回车CP→回车DP→回车YD→改CP(不回车) → 最终仅CP留REV', () => { + const item = makeBondItem(); + // 三字段都回车过 → YD是最终源 + SwapCalc.applyBondCalcSuccess(item, 'CP'); + SwapCalc.applyBondCalcSuccess(item, 'DP'); + SwapCalc.applyBondCalcSuccess(item, 'YD'); + expect(item.bondDriverType).toBe('YD'); + + // 手工编辑CP(不回车)→ 全部标识降级,仅CP留REV + SwapCalc.applyBondManualEdit(item, 'CP'); + expect(item.bondDriverType).toBeNull(); + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: true, DP: false, YD: false }); + }); + + test('多字段连续编辑时数值始终保留、不联动(约定3核心语义)', () => { + const item = makeBondItem(); + // 设定初始值 + item.PosiNetNoFeePrice = 0.995; // 净价 99.5 + item.PosiGrossPrice = 1.0; // 全价 100 + item.InitYtm = 0.03; // 收益率 3% + + // 回车CP触发计算(模拟成功——数值会被覆盖,但这里只看标识) + SwapCalc.applyBondCalcSuccess(item, 'CP'); + + // 连续编辑另两字段(不回车)→ 数值不应被联动修改 + SwapCalc.applyBondManualEdit(item, 'DP'); + expect(item.PosiNetNoFeePrice).toBe(0.995); + expect(item.PosiGrossPrice).toBe(1.0); + expect(item.InitYtm).toBe(0.03); + + SwapCalc.applyBondManualEdit(item, 'YD'); + expect(item.PosiNetNoFeePrice).toBe(0.995); // 净价不变 + expect(item.PosiGrossPrice).toBe(1.0); // 全价不变 + expect(item.InitYtm).toBe(0.03); // 收益率不变 + + // 再编辑回CP本身 + SwapCalc.applyBondManualEdit(item, 'CP'); + expect(item.PosiNetNoFeePrice).toBe(0.995); // 仍然不变 + expect(item.PosiGrossPrice).toBe(1.0); + expect(item.InitYtm).toBe(0.03); + }); +}); + +// ============================================================================ +// 回归守卫:onBondPriceKeydown 的键码过滤逻辑 +// ============================================================================ +// 验证 onBondPriceKeydown 中"哪些键触发清标识、哪些不触发"的判断逻辑。 +// 回车(13/108)除外——由 v-on:enter 驱动;修饰键(Ctrl/Alt/Meta)除外; +// Tab(9)/Home(35)/End(36)/左(37)/右(39)/F5(116)除外——不改变数值。 +describe('onBondPriceKeydown 键码过滤逻辑回归', () => { + + // 复刻 onBondPriceKeydown 的过滤判断(不含 Vue 调用) + function shouldClearFlags(event) { + var kc = event.which || event.keyCode; + if (kc === 13 || kc === 108) return false; // 回车 + if (event.ctrlKey || event.altKey || event.metaKey) return false; // 修饰键 + if (kc === 9 || kc === 35 || kc === 36 || kc === 37 || kc === 39 || kc === 116) return false; // 导航/功能键 + return true; // 其余按键(数字/小数点/Backspace/Delete/减号)改变数值 → 清标识 + } + + test('回车键(13/108)不触发清标识', () => { + expect(shouldClearFlags({ keyCode: 13 })).toBe(false); + expect(shouldClearFlags({ keyCode: 108 })).toBe(false); + }); + + test('修饰键不触发清标识', () => { + expect(shouldClearFlags({ keyCode: 67, ctrlKey: true })).toBe(false); // Ctrl+C + expect(shouldClearFlags({ keyCode: 86, ctrlKey: true })).toBe(false); // Ctrl+V + expect(shouldClearFlags({ keyCode: 65, altKey: true })).toBe(false); // Alt+A + expect(shouldClearFlags({ keyCode: 82, metaKey: true })).toBe(false); // Cmd+R + }); + + test('导航/功能键不触发清标识', () => { + expect(shouldClearFlags({ keyCode: 9 })).toBe(false); // Tab + expect(shouldClearFlags({ keyCode: 35 })).toBe(false); // End + expect(shouldClearFlags({ keyCode: 36 })).toBe(false); // Home + expect(shouldClearFlags({ keyCode: 37 })).toBe(false); // Left + expect(shouldClearFlags({ keyCode: 39 })).toBe(false); // Right + expect(shouldClearFlags({ keyCode: 116 })).toBe(false); // F5 + }); + + test('数字键触发清标识', () => { + expect(shouldClearFlags({ keyCode: 48 })).toBe(true); // 0 + expect(shouldClearFlags({ keyCode: 57 })).toBe(true); // 9 + expect(shouldClearFlags({ keyCode: 96 })).toBe(true); // Numpad 0 + expect(shouldClearFlags({ keyCode: 105 })).toBe(true); // Numpad 9 + }); + + test('Backspace(8)/Delete(46)触发清标识', () => { + expect(shouldClearFlags({ keyCode: 8 })).toBe(true); // Backspace + expect(shouldClearFlags({ keyCode: 46 })).toBe(true); // Delete + }); + + test('小数点(110/190)触发清标识', () => { + expect(shouldClearFlags({ keyCode: 110 })).toBe(true); // Numpad dot + expect(shouldClearFlags({ keyCode: 190 })).toBe(true); // Dot + }); + + test('减号(109/189)触发清标识', () => { + expect(shouldClearFlags({ keyCode: 109 })).toBe(true); // Numpad minus + expect(shouldClearFlags({ keyCode: 189 })).toBe(true); // Minus + }); +}); diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index 08f6ad7b..6595bdab 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -1,126 +1,94 @@ /** - * bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫 + * bondCalc.test.js — 债券净价/全价/收益率三字段互算(driver 源 + 交互约定2/3 状态机) * ============================================================================ - * 核心保证(用户最担心的,也是第一天讨论的诉求): - * 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它; - * 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖。 - * 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。 + * 模型(交互约定1/2/3): + * - driver(bondDriverType):回车的"源"字段(CP净价 / DP全价 / YD收益率),恒为最后回车者。 + * - bondAuto:计算器算出的另两个字段("AUTO")。 + * - bondRev:编辑未回车的人工输入字段("REV")。 + * - applyBondCalcResult(state, calc, driver):回写时跳过 driver 源字段(保留用户手输值), + * 另两字段用计算器结果覆盖——即"回车某字段即以它为源反算另两个"。 * * 运行:cd YLErpWeb/fe-tests && npm i && npm test - * 纯逻辑在 swapCalc.js::applyBondCalcResult(UMD,node 可直接 require)。 + * 纯逻辑在 swapCalc.js(UMD,node 可直接 require)。 */ const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; -describe('逐字段手动锁定:手填过的字段永不回写', () => { - test('仅手填净价(CP):净价保持,全价/收益率被反算覆盖', () => { - const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; // 用户手填净价 99.5 - SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: false }); - expect(state.cleanPrice).toBe(99.5); // 手填:绝不回写 - expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 +describe('以 driver 为源:回写时源字段保持、另两字段被反算覆盖', () => { + test('回车净价(CP)为源:净价保持,全价/收益率被反算覆盖', () => { + const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; + SwapCalc.applyBondCalcResult(state, CALC, 'CP'); + expect(state.cleanPrice).toBe(99.5); // 源:保持 + expect(state.dirtyPrice).toBe(100); // 非源:被反算覆盖 expect(state.ytm).toBe(2.5); }); - test('手填净价+全价(CP&DP):两者保持,仅收益率被覆盖', () => { - const state = { cleanPrice: 99.5, dirtyPrice: 100.5, ytm: null }; - SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: false }); - expect(state.cleanPrice).toBe(99.5); // 手填:保持 - expect(state.dirtyPrice).toBe(100.5); // 手填:保持 - expect(state.ytm).toBe(2.5); // 未手填:被反算覆盖 + test('回车全价(DP)为源:全价保持,净价/收益率被反算覆盖', () => { + const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; + SwapCalc.applyBondCalcResult(state, CALC, 'DP'); + expect(state.dirtyPrice).toBe(100.5); // 源:保持 + expect(state.cleanPrice).toBe(99); + expect(state.ytm).toBe(2.5); }); - test('用户不认可计算结果、手填全部三个:全部保留,无一被回写(核心场景)', () => { - const state = { cleanPrice: 98, dirtyPrice: 101, ytm: 3.0 }; // 用户全手填 - SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: true }); - expect(state.cleanPrice).toBe(98); // 全部手填 → 全部保留 - expect(state.dirtyPrice).toBe(101); - expect(state.ytm).toBe(3.0); - }); - - test('手填收益率(YD):收益率保持,净价/全价被覆盖', () => { + test('回车收益率(YD)为源:收益率保持,净价/全价被反算覆盖', () => { const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; - SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: false, YD: true }); - expect(state.ytm).toBe(2.6); + SwapCalc.applyBondCalcResult(state, CALC, 'YD'); + expect(state.ytm).toBe(2.6); // 源:保持 expect(state.cleanPrice).toBe(99); expect(state.dirtyPrice).toBe(100); }); - - test('仅手填全价(DP):全价保持,净价/收益率被反算覆盖(最常用场景)', () => { - const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; // 用户手填全价 100.5 - SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: false }); - expect(state.dirtyPrice).toBe(100.5); // 手填:绝不回写 - expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 - expect(state.ytm).toBe(2.5); - }); - - test('手填净价+收益率(CP&YD):两者保持,仅全价被覆盖', () => { - const state = { cleanPrice: 98.5, dirtyPrice: null, ytm: 3.2 }; - SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: true }); - expect(state.cleanPrice).toBe(98.5); // 手填:保持 - expect(state.ytm).toBe(3.2); // 手填:保持 - expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 - }); - - test('手填全价+收益率(DP&YD):两者保持,仅净价被覆盖', () => { - const state = { cleanPrice: null, dirtyPrice: 101, ytm: 2.8 }; - SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: true }); - expect(state.dirtyPrice).toBe(101); // 手填:保持 - expect(state.ytm).toBe(2.8); // 手填:保持 - expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖 - }); }); describe('边界:计算器未返回的值不覆盖、无变化不写', () => { - test('手填净价 且 计算器未返收益率:源与缺失值均不写', () => { + test('回车净价(源) 且 计算器未返收益率:源与缺失值均不写', () => { const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined }; - SwapCalc.applyBondCalcResult(state, partial, { CP: true, DP: false, YD: false }); - expect(state.cleanPrice).toBe(null); // CP 手填:即便 calc 返 99 也不写 - expect(state.dirtyPrice).toBe(100); // 未手填:被覆盖 - expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值 + SwapCalc.applyBondCalcResult(state, partial, 'CP'); + expect(state.cleanPrice).toBe(null); // CP 源:即便 calc 返 99 也不写 + expect(state.dirtyPrice).toBe(100); // 非源:被覆盖 + expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值 }); test('派生值与当前值差异 { const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 }; const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 }; - SwapCalc.applyBondCalcResult(state, near, { CP: true, DP: false, YD: false }); - expect(state.cleanPrice).toBe(99.5); // 手填:不写 - expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写 + SwapCalc.applyBondCalcResult(state, near, 'CP'); + expect(state.cleanPrice).toBe(99.5); // 源:不写 + expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写 expect(state.ytm).toBe(2.5); }); // 模拟 calcBondForItem:proxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。 - test('浮动腿 item 写回:仅未手填字段被覆盖,且存储态转换正确', () => { + test('浮动腿 item 写回:仅非源字段被覆盖,且存储态转换正确', () => { // 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995 - const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, - bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, bondDriverType: 'CP' }; const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态 const proxy = { - cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5(手填) + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5(源) dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100 ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6 }; - SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + SwapCalc.applyBondCalcResult(proxy, calc, 'CP'); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); - expect(item.PosiNetNoFeePrice).toBe(0.995); // 手填(净价):存储态保持 0.995(界面仍显示 99.5) + expect(item.PosiNetNoFeePrice).toBe(0.995); // 源(净价):存储态保持 0.995(界面仍显示 99.5) expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0 expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025 }); // 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01 test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => { - const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, - bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP' }; const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 }; const proxy = { cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100 dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), ytm: SwapCalc.bondPriceToCalc(item.InitYtm) }; - SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + SwapCalc.applyBondCalcResult(proxy, calc, 'CP'); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); @@ -135,15 +103,14 @@ describe('边界:计算器未返回的值不覆盖、无变化不写', () => { [105, 107.3, 4.567890], [100, 102.17928767123287, 4.860445236] ])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => { - const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, - bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP' }; const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm }; const proxy = { cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), ytm: SwapCalc.bondPriceToCalc(item.InitYtm) }; - SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + SwapCalc.applyBondCalcResult(proxy, calc, 'CP'); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); @@ -212,22 +179,69 @@ describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); }); - test('端到端:业务错误时不覆盖手工输入(仅提示)', () => { - const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, - bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + test('端到端:业务错误时保留源字段、清空另两字段与标识(交互约定2 失败)', () => { + const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, bondDriverType: 'CP' }; const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 }; const err = SwapCalc.getBondCalcErrorMessage(respObj); - expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并 return - if (!err) { - const proxy = { cleanPrice: item.PosiNetNoFeePrice, dirtyPrice: item.PosiGrossPrice, ytm: item.InitYtm }; - SwapCalc.applyBondCalcResult(proxy, respObj, item.bondManual); - item.PosiNetNoFeePrice = proxy.cleanPrice; - item.PosiGrossPrice = proxy.dirtyPrice; - item.InitYtm = proxy.ytm; - } - expect(item.PosiNetNoFeePrice).toBe(99.5); - expect(item.PosiGrossPrice).toBe(100.0); - expect(item.InitYtm).toBe(2.6); + expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并调 applyBondCalcFailure + if (err) SwapCalc.applyBondCalcFailure(item, 'CP'); + expect(item.PosiNetNoFeePrice).toBe(99.5); // 源:保留用户输入 + expect(item.PosiGrossPrice).toBeNull(); // 非源:清空 + expect(item.InitYtm).toBeNull(); + expect(item.bondDriverType).toBeNull(); // 三字段标识全清 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); +}); + +// ============================================================================ +// 交互约定1/2/3 状态机:applyBondCalcSuccess / applyBondCalcFailure / applyBondManualEdit +// ============================================================================ +describe('交互约定2/3 状态机:applyBondCalcSuccess / Failure / ManualEdit', () => { + test('applyBondCalcSuccess(CP):源=CP,AUTO={DP,YD},REV 清空', () => { + const s = { bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: true, DP: false, YD: false } }; + SwapCalc.applyBondCalcSuccess(s, 'CP'); + expect(s.bondDriverType).toBe('CP'); + expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true }); + expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('applyBondCalcSuccess(DP):源=DP,AUTO={CP,YD}', () => { + const s = {}; + SwapCalc.applyBondCalcSuccess(s, 'DP'); + expect(s.bondDriverType).toBe('DP'); + expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true }); + }); + + test('applyBondCalcFailure(CP):源值保留,另两字段清空,标识全清', () => { + const s = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100, InitYtm: 2.6, + bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } }; + SwapCalc.applyBondCalcFailure(s, 'CP'); + expect(s.PosiNetNoFeePrice).toBe(99.5); // 源:保留 + expect(s.PosiGrossPrice).toBeNull(); // 非源:清空 + expect(s.InitYtm).toBeNull(); + expect(s.bondDriverType).toBeNull(); + expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false }); + }); + + test('applyBondManualEdit(DP):清 源/AUTO,仅 DP 标 REV(交互约定3)', () => { + const s = { bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } }; + SwapCalc.applyBondManualEdit(s, 'DP'); + expect(s.bondDriverType).toBeNull(); + expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(s.bondRev).toEqual({ CP: false, DP: true, YD: false }); // 仅本字段 REV + }); + + test('全价回车后净价再回车:净价成新源、全价降级为 AUTO(最后回车者恒为源)', () => { + const s = { PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null, + bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: false, DP: false, YD: false } }; + SwapCalc.applyBondCalcSuccess(s, 'DP'); // 全价回车 + expect(s.bondDriverType).toBe('DP'); + expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true }); + SwapCalc.applyBondCalcSuccess(s, 'CP'); // 净价再回车 + expect(s.bondDriverType).toBe('CP'); + expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true }); // 全价从源降级为 AUTO }); }); @@ -262,3 +276,55 @@ describe('单位换算边界(前端↔债券计算器 存储态小数 ↔ 展 expect(modelYtm * 100).toBeCloseTo(6.37, 4); }); }); + +describe('isBondPriceValueKey:只有真正改值的按键才标REV(防止"没改值却REV")', () => { + const K = (keyCode, extra) => Object.assign({ which: keyCode, keyCode: keyCode }, extra || {}); + test('数字键(主键盘/小键盘) → 改值', () => { + expect(SwapCalc.isBondPriceValueKey(K(49))).toBe(true); // 主键盘 1 + expect(SwapCalc.isBondPriceValueKey(K(97))).toBe(true); // 小键盘 1 + }); + test('小数点/减号(主键盘/小键盘) → 改值', () => { + expect(SwapCalc.isBondPriceValueKey(K(190))).toBe(true); // 主键盘 . + expect(SwapCalc.isBondPriceValueKey(K(110))).toBe(true); // 小键盘 . + expect(SwapCalc.isBondPriceValueKey(K(189))).toBe(true); // 主键盘 - + expect(SwapCalc.isBondPriceValueKey(K(109))).toBe(true); // 小键盘 - + }); + test('Backspace / Delete → 改值', () => { + expect(SwapCalc.isBondPriceValueKey(K(8))).toBe(true); + expect(SwapCalc.isBondPriceValueKey(K(46))).toBe(true); + }); + test('回车 → 不改标识(由 enter 事件处理)', () => { + expect(SwapCalc.isBondPriceValueKey(K(13))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(108))).toBe(false); + }); + test('方向键 ←↑→↓ → 不改标识(修复:此前漏掉 ↑↓ 会误标REV)', () => { + expect(SwapCalc.isBondPriceValueKey(K(37))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(38))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(39))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(40))).toBe(false); + }); + test('Home/End/Tab/Insert/PageUp/PageDown → 不改标识(修复:此前漏掉 Insert/PageUp/PageDown)', () => { + expect(SwapCalc.isBondPriceValueKey(K(35))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(36))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(9))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(45))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(33))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(34))).toBe(false); + }); + test('F1-F12 → 不改标识(修复:此前漏掉除 F5 外的功能键)', () => { + [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123].forEach(function (kc) { + expect(SwapCalc.isBondPriceValueKey(K(kc))).toBe(false); + }); + }); + test('纯 Shift / Ctrl / Alt / Meta 组合键 → 不改标识', () => { + expect(SwapCalc.isBondPriceValueKey(K(16))).toBe(false); // 纯 Shift + expect(SwapCalc.isBondPriceValueKey(K(65, { ctrlKey: true }))).toBe(false); // Ctrl+A + expect(SwapCalc.isBondPriceValueKey(K(86, { ctrlKey: true }))).toBe(false); // Ctrl+V + expect(SwapCalc.isBondPriceValueKey(K(67, { altKey: true }))).toBe(false); + expect(SwapCalc.isBondPriceValueKey(K(67, { metaKey: true }))).toBe(false); + }); + test('无事件对象 → 安全返回 false', () => { + expect(SwapCalc.isBondPriceValueKey(null)).toBe(false); + expect(SwapCalc.isBondPriceValueKey(undefined)).toBe(false); + }); +}); diff --git a/YLErpWeb/fe-tests/diag.test.js b/YLErpWeb/fe-tests/diag.test.js new file mode 100644 index 00000000..7a8ba756 --- /dev/null +++ b/YLErpWeb/fe-tests/diag.test.js @@ -0,0 +1,189 @@ +/** + * diag.test.js — 运行时诊断机制测试(版本可追溯 + 可控调试日志) + * ============================================================================ + * 目的:守卫"可保留调试日志 + 版本可追溯"机制,让排查 EQD-6838 这类"改了不生效" + * 问题时,F12 能一键开启详细日志并看到精确版本(jsVersion + git sha)。 + * + * 覆盖: + * 1. 开关解析:URL ?otcdebug=1 / localStorage.otcdebug=1 解析正确 + * 2. 静默性:debug=false 时 otcDebug 不输出(生产零噪音) + * 3. banner 格式:debug=true 时输出含 jsVersion + git(版本可追溯) + * 4. 防回归:业务文件不再硬编码 v20260729a 这类易过期的版本串 + * + * 建立 console spy 先例:项目此前零 `jest.spyOn(console)` 用法,本测试建立范式。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest diag + */ + +const { JSDOM } = require('jsdom'); +const fs = require('fs'); +const path = require('path'); + +// ---- 与 main.js 顶部一致的 otcDebug 实现(行为黄金标准) ---- +// 为什么复制而非 require main.js:main.js 第 5 行 `main.extend = $.extend` 依赖 jQuery+lodash, +// 完整 require 代价过大;otcDebug 工具是纯函数(不依赖 $),提取出来做行为验证最干净。 +// main.js 源码一致性由下方"源码守卫"用例保证(断言关键片段存在)。 +function createOtcDebug(diag) { + var __otcDiag = diag || { debug: false }; + return { + banner: function (name, ver) { + if (!__otcDiag.debug || !console) return; + var git = (__otcDiag.git || '').slice(0, 7); + console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')', + 'color:#06c;font-weight:bold'); + }, + log: function () { + if (!__otcDiag.debug || !console) return; + console.log.apply(console, arguments); + } + }; +} + +// ---- 与 _MainLayout.cshtml 一致的 diag 开关解析(行为黄金标准) ---- +function parseDebugFlag(search, localStorageValue) { + return /[?&]otcdebug=1/.test(search) + || (localStorageValue === '1'); +} + +describe('diag: 开关解析(URL / localStorage)', () => { + test('?otcdebug=1 在 query 中 → 开启', () => { + expect(parseDebugFlag('?otcdebug=1', null)).toBe(true); + expect(parseDebugFlag('/swap/edit?otcdebug=1&id=5', null)).toBe(true); + }); + test('?otcdebug=1 作为唯一参数 → 开启', () => { + expect(parseDebugFlag('?otcdebug=1', null)).toBe(true); + }); + test('URL 无参数 → 关闭', () => { + expect(parseDebugFlag('', null)).toBe(false); + expect(parseDebugFlag('/swap/edit', null)).toBe(false); + }); + test('localStorage.otcdebug=1 → 持久开启', () => { + expect(parseDebugFlag('', '1')).toBe(true); + }); + test('localStorage 其它值 → 关闭', () => { + expect(parseDebugFlag('', null)).toBe(false); + expect(parseDebugFlag('', '0')).toBe(false); + expect(parseDebugFlag('', '')).toBe(false); + }); + test('URL 和 localStorage 任一为真即开启(OR 语义)', () => { + expect(parseDebugFlag('?otcdebug=1', '0')).toBe(true); + expect(parseDebugFlag('', '1')).toBe(true); + expect(parseDebugFlag('?foo=bar', '0')).toBe(false); + }); +}); + +describe('diag: otcDebug 静默性(debug=false 生产零输出)', () => { + let logSpy; + beforeEach(() => { logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + test('debug=false 时 banner 不输出', () => { + const dbg = createOtcDebug({ debug: false, jsVersion: '2507300000', git: 'abc1234', built: '2026-07-30' }); + dbg.banner('swapTradeEdit.js', '1.4.2'); + expect(logSpy).not.toHaveBeenCalled(); + }); + test('debug=false 时 log 不输出', () => { + const dbg = createOtcDebug({ debug: false }); + dbg.log('排查信息', { a: 1 }); + expect(logSpy).not.toHaveBeenCalled(); + }); + test('无 diag 对象时默认静默', () => { + const dbg = createOtcDebug(undefined); + dbg.banner('x', '1'); dbg.log('y'); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); + +describe('diag: banner 输出含完整版本信息(可追溯)', () => { + let logSpy; + beforeEach(() => { logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); }); + afterEach(() => { logSpy.mockRestore(); }); + + test('banner 输出包含 模块名 + jsVersion + git sha 前7位 + built', () => { + const diag = { debug: true, jsVersion: '2507301200', git: 'f5ed65aac5f93028', built: '2026-07-30 10:00:00' }; + const dbg = createOtcDebug(diag); + dbg.banner('swapTradeEdit.js', '1.4.2'); + expect(logSpy).toHaveBeenCalledTimes(1); + const out = logSpy.mock.calls[0][0]; + expect(out).toContain('swapTradeEdit.js'); + expect(out).toContain('1.4.2'); + expect(out).toContain('2507301200'); // jsVersion + expect(out).toContain('f5ed65a'); // git sha 前7位(被 slice 截断) + expect(out).not.toContain('f5ed65aac5f93028'); // 不含完整 sha(避免 console 过长) + expect(out).toContain('2026-07-30 10:00:00'); // built + }); + test('log 透传所有参数', () => { + const dbg = createOtcDebug({ debug: true }); + dbg.log('事件', 'keydown', { key: 'Enter' }); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0]).toEqual(['事件', 'keydown', { key: 'Enter' }]); + }); + test('git 缺失时 banner 不崩(输出 git= 空)', () => { + const dbg = createOtcDebug({ debug: true, jsVersion: '1', git: undefined, built: '' }); + expect(() => dbg.banner('m', '1')).not.toThrow(); + expect(logSpy.mock.calls[0][0]).toContain('git='); + }); +}); + +describe('diag: 源码守卫(防回归——不许再硬编码易过期版本串)', () => { + const SCRIPTS = path.join(__dirname, '..', 'wwwroot', 'Scripts'); + + test('swapTradeEdit.js 顶部 banner 不再硬编码 v20260729a', () => { + const src = fs.readFileSync(path.join(SCRIPTS, 'app/swaptrade/swapTradeEdit.js'), 'utf8'); + expect(src).not.toContain('v20260729a'); + // 必须改为受开关控制的动态 banner + expect(src).toMatch(/otcDebug\.banner|window\.ylotc\.__diag/); + }); + + test('swapTradeEdit.js 三字段调试埋点必须 gated(otcDebug.log),不得裸 console.log 退回', () => { + // 防回归:历史多次"改了不生效"靠日志兜底,曾出现①把 console.log 直接删了(出问题时看不见) + // ②或解除了开关控制(生产噪音)。正确做法是 otcDebug.log(?otcdebug=1 才输出)。 + const src = fs.readFileSync(path.join(SCRIPTS, 'app/swaptrade/swapTradeEdit.js'), 'utf8'); + // 存在 gated 埋点 + expect(src).toMatch(/otcDebug\.log\(/); + // 关键路径不得出现裸 console.log 调试行 + expect(src).not.toMatch(/console\.log\('\[onBondPriceKeydown\]/); + expect(src).not.toMatch(/console\.log\('\[calcBondForItem\]/); + expect(src).not.toMatch(/console\.log\('\[onBondPriceEdit\]/); + }); + + test('fastVue.base.js 顶部 banner 不再硬编码 v20260729a', () => { + const src = fs.readFileSync(path.join(SCRIPTS, 'fast/fastVue.base.js'), 'utf8'); + expect(src).not.toContain('v20260729a'); + expect(src).toMatch(/window\.ylotc\.__diag/); + }); + + test('main.js 提供 otcDebug 工具且挂在 window', () => { + const src = fs.readFileSync(path.join(SCRIPTS, 'base/main.js'), 'utf8'); + expect(src).toContain('window.otcDebug'); + expect(src).toContain('window.ylotc.__diag'); + // 工具必须实现 banner + log 两个方法 + expect(src).toMatch(/banner\s*[:=]\s*function/); + expect(src).toMatch(/log\s*[:=]\s*function/); + }); + + test('main.js 不存在第二套调试开关(机制 A 防回归)', () => { + // 历史教训:曾同时存在两套重叠的调试开关—— + // 机制A: ?debug=1 / localStorage.__yl_debug__ / main.debugLog/debugBanner + // 机制B: ?otcdebug=1 / localStorage.otcdebug / window.otcDebug(统一方案,保留) + // 两套并存导致排查者记两套参数、两个前缀、fallback 分支。本守卫确保只有一套。 + const src = fs.readFileSync(path.join(SCRIPTS, 'base/main.js'), 'utf8'); + expect(src).not.toContain('main.debugLog'); + expect(src).not.toContain('main.debugWarn'); + expect(src).not.toContain('main.debugError'); + expect(src).not.toContain('main.debugBanner'); + expect(src).not.toContain('main.setDebug'); + expect(src).not.toContain('__yl_debug__'); + expect(src).not.toContain('?debug=1'); + }); + + test('bundle 产物不再含硬编码 v20260729a(源文件已清除,重建产物自然不含)', () => { + // 版本可追溯不靠 bundle 头部注释(那会随 commit 变化导致 verify 永久失败), + // 而是靠运行时 window.ylotc.__diag(后端 HtmlUtil.GitCommit 注入)+ otcDebug.banner。 + // 本守卫确保源文件的硬编码清除后,重建产物不会重新带回它。 + const bundle = path.join(__dirname, '..', 'wwwroot', 'Statics', 'bundles', 'bundle.js'); + if (!fs.existsSync(bundle)) return; // 产物可能尚未重建,跳过 + const src = fs.readFileSync(bundle, 'utf8'); + expect(src).not.toContain('v20260729a'); + }); +}); diff --git a/YLErpWeb/fe-tests/eodPositionRisks.test.js b/YLErpWeb/fe-tests/eodPositionRisks.test.js new file mode 100644 index 00000000..908528da --- /dev/null +++ b/YLErpWeb/fe-tests/eodPositionRisks.test.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function loadEodPositionRiskHelpers() { + const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/EodPositionRisks.js'); + const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { TradeDirectionFormat };'; + const sandbox = { + module: { exports: {} }, + exports: {}, + $() { }, + main: { + numberFormat() { + return function () { }; + } + } + }; + + vm.runInNewContext(code, sandbox, { filename: filePath }); + return sandbox.module.exports; +} + +const { TradeDirectionFormat } = loadEodPositionRiskHelpers(); + +describe('互换日终持仓交易方向', () => { + test.each([ + [1, 1, '多头'], + [1, 2, '空头'], + [2, 1, '空头'], + [2, 2, '多头'] + ])('收支方向=%i,多空方向=%i时展示%s', (posiDirection, positionType, expected) => { + const row = { eodPosition: { PosiDirection: posiDirection, PositionType: positionType } }; + expect(TradeDirectionFormat(positionType, {}, row)).toBe(expected); + }); + + test('无有效浮动端方向时不展示交易方向', () => { + expect(TradeDirectionFormat(1, {}, { eodPosition: { PosiDirection: 0, PositionType: 1 } })).toBe(''); + }); +}); diff --git a/YLErpWeb/fe-tests/fastVue.enter.test.js b/YLErpWeb/fe-tests/fastVue.enter.test.js new file mode 100644 index 00000000..e1753e5a --- /dev/null +++ b/YLErpWeb/fe-tests/fastVue.enter.test.js @@ -0,0 +1,224 @@ +/** + * fastVue.enter.test.js — FastVue.numberInput 回车(enter)事件链路验证 + * ============================================================================ + * 目的:验证 __keyHandle Enter → _enterFired=true → blur() → __change 读 isEnter + * → onchange(f, text, isEnter=true) 完整链路。 + * + * 背景:债券三字段交互改造新增 enter 事件区分回车/失焦; + * 生产环境回车不触发计算(REV 显示正常说明 input 事件通路 OK), + * 需要脱离浏览器独立验证控件改动本身是否正确。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest fastVue.enter + */ + +const { JSDOM } = require('jsdom'); +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; +global.navigator = dom.window.navigator; + +Object.defineProperty(global, 'FastVue', { + get() { return global.window.FastVue; }, + set(value) { global.window.FastVue = value; }, + configurable: true +}); + +const $ = require('jquery'); +global.$ = $; +global.jQuery = $; + +global.main = { + formatNumber: function (number, precision, options) { + const n = Number(number); + if (Number.isNaN(n)) return '0'; + const useGrouping = options && options.grouping; + if (useGrouping) { + return n.toLocaleString('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }); + } + return n.toFixed(precision); + } +}; + +require('../wwwroot/Scripts/fast/fastVue.base.js'); + +function createInput() { + return document.createElement('input'); +} + +/** + * 模拟用户在 input 中输入值后敲回车的完整流程: + * 1. 设置 value + * 2. 触发 keydown(13=Enter) → __keyHandle 应设置 _enterFired=true 并 blur() + * 3. 触发 change 事件 → __change 应读取 _enterFired 并传 isEnter=true 给 onchange + */ +function simulateEnter(el, inputValue) { + el.value = inputValue; + // Step 1: keydown Enter (keyCode 13) + const keydown = new window.KeyboardEvent('keydown', { keyCode: 13, bubbles: true }); + el.dispatchEvent(keydown); + // Step 2: change 事件(blur 后浏览器触发;jsdom 不自动触发,需手动) + const change = new window.Event('change', { bubbles: true }); + el.dispatchEvent(change); +} + +/** + * 模拟用户在 input 中输入值后点击别处失焦(非回车)的流程: + * keydown 非 Enter → _enterFired 保持 false → change → isEnter=false + */ +function simulateBlurOnly(el, inputValue) { + el.value = inputValue; + // 先随便按个键(如 End 键 keyCode 35),让 __keyHandle 跑一遍但 _enterFired=false + const keydown = new window.KeyboardEvent('keydown', { keyCode: 35, bubbles: true }); + el.dispatchEvent(keydown); + // 再触发 change(模拟失焦) + const change = new window.Event('change', { bubbles: true }); + el.dispatchEvent(change); +} + +describe('FastVue.numberInput enter 事件链路', () => { + + test('回车(Enter)时 onchange 应收到 isEnter=true(三参数签名)', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 4, onchange: (f, text, isEnter) => calls.push({ f, text, isEnter }) }); + + simulateEnter(el, '100'); + + // 核心断言:onchange 被调用且 isEnter === true + expect(calls.length).toBeGreaterThanOrEqual(1); + const lastCall = calls[calls.length - 1]; + expect(lastCall.isEnter).toBe(true); + expect(lastCall.f).toBeCloseTo(100, 6); + expect(lastCall.text).toBe('100'); + }); + + test('失焦(非回车)时 onchange 应收到 isEnter=false', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 4, onchange: (f, text, isEnter) => calls.push({ f, text, isEnter }) }); + + simulateBlurOnly(el, '99.5'); + + expect(calls.length).toBeGreaterThanOrEqual(1); + const lastCall = calls[calls.length - 1]; + expect(lastCall.isEnter).toBe(false); + }); + + test('连续两次回车:每次都应 isEnter=true', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 2, onchange: (f, text, isEnter) => calls.push({ isEnter }) }); + + simulateEnter(el, '100'); + simulateEnter(el, '101'); + + // 至少有 2 次 onchange 调用(每次 Enter 各一次) + expect(calls.length).toBeGreaterThanOrEqual(2); + calls.forEach((call, i) => { + expect(call.isEnter).toBe(true); // 每次 Enter 都应 isEnter=true + }); + }); + + test('先失焦再回车:第一次 isEnter=false,第二次 isEnter=true', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 2, onchange: (f, text, isEnter) => calls.push({ isEnter }) }); + + simulateBlurOnly(el, '99'); // 失焦 + simulateEnter(el, '100'); // 回车 + + expect(calls.length).toBeGreaterThanOrEqual(2); + expect(calls[0].isEnter).toBe(false); // 失焦 + expect(calls[calls.length - 1].isEnter).toBe(true); // 回车 + }); + + test('percent:true 格式下回车:isEnter=true 且值正确 ÷100', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 4, percent: true, append: '', onchange: (f, text, isEnter) => calls.push({ f, text, isEnter }) }); + + simulateEnter(el, '99.5'); + + const lastCall = calls[calls.length - 1]; + expect(lastCall.isEnter).toBe(true); + // percent:true 下原始值 99.5 → 解析为 0.995 + expect(lastCall.f).toBeCloseTo(0.995, 8); + expect(lastCall.text).toBe('99.5'); + }); + + test('append:"%" 格式下回车:isEnter=true 且值正确 ÷100', () => { + const el = createInput(); + const calls = []; + FastVue.numberInput(el, { precision: 2, append: '%', onchange: (f, text, isEnter) => calls.push({ f, text, isEnter }) }); + + simulateEnter(el, '99.50%'); + + const lastCall = calls[calls.length - 1]; + expect(lastCall.isEnter).toBe(true); + expect(lastCall.f).toBeCloseTo(0.995, 6); // "99.50%" → 99.50 / 100 = 0.995 + }); +}); + +describe('FastVue.vueNumberInput 组件 onchange 方法($emit enter/input 分发)', () => { + + /** + * 创建一个模拟的 Vue 组件实例,包含 $emit 收集 + vueNumberInput 的 onchange 方法。 + * 用 Function.prototype.bind 确保 this 指向 vm(模拟 Vue 1.x 的方法绑定)。 + */ + function createMockVm() { + const emitted = []; + const vm = { + $emit(event, val) { emitted.push({ event, val }); }, + ret_type: 0, + init: true, + value: null + }; + const ctor = FastVue.vueNumberInput(); + // 用 bind 固定 this 为 vm,模拟 Vue 组件的方法绑定 + vm.onchange = ctor.methods.onchange.bind(vm); + return { vm, emitted }; + } + + test('回车(isEnter=true)时:$emit("input") 和 $emit("enter") 都应触发', () => { + const { vm, emitted } = createMockVm(); + + // 直接调用 onchange(模拟 __change 回调),传入 isEnter=true + vm.onchange(100, '100', true); + + expect(emitted.some(e => e.event === 'input')).toBe(true); + expect(emitted.some(e => e.event === 'enter')).toBe(true); + + const inputEvt = emitted.find(e => e.event === 'input'); + const enterEvt = emitted.find(e => e.event === 'enter'); + expect(inputEvt.val).toBe(100); + expect(enterEvt.val).toBe(100); + }); + + test('失焦(isEnter=false)时:只 $emit("input"),不 $emit("enter")', () => { + const { vm, emitted } = createMockVm(); + + vm.onchange(99.5, '99.5', false); + + expect(emitted.some(e => e.event === 'input')).toBe(true); + expect(emitted.every(e => e.event !== 'enter')).toBe(true); + }); + + test('ret_type=1 时:$emit 传 text 而非 number', () => { + const emitted = []; + const vm = { + $emit(event, val) { emitted.push({ event, val }); }, + ret_type: 1, // ret_type=1 → emit text 原值 + init: true, + value: null + }; + const ctor = FastVue.vueNumberInput(); + vm.onchange = ctor.methods.onchange.bind(vm); + + vm.onchange(99.5, '99.50%', true); + + expect(emitted.some(e => e.event === 'enter')).toBe(true); + const enterEvt = emitted.find(e => e.event === 'enter'); + // ret_type=1 → emit text ('99.50%') 而非 parsed number + expect(enterEvt.val).toBe('99.50%'); + }); +}); diff --git a/YLErpWeb/fe-tests/hooks/pre-commit b/YLErpWeb/fe-tests/hooks/pre-commit new file mode 100755 index 00000000..1f817380 --- /dev/null +++ b/YLErpWeb/fe-tests/hooks/pre-commit @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# ============================================================================= +# pre-commit — 提交前自动守卫 +# ============================================================================= +# 做三件事(任一失败即阻断提交): +# 1. guard_arch.js — 扫描新增/修改行,禁止在 Vue 组件里内联金额计算 +# 2. jest — 跑前端单测,确保不引入回归 +# 3. bundle 校验 — rebuild-bundles.py --verify,拦截"改源文件忘重新打 bundle" +# +# 安装方式(开发者只需执行一次): +# cd +# cp YLErpWeb/fe-tests/hooks/pre-commit .git/hooks/pre-commit +# chmod +x .git/hooks/pre-commit +# +# 跳过方式(紧急情况,不推荐): +# git commit --no-verify +# +# CI 也应调用本脚本(或等价的 npm test + node guard_arch.js)。 +# ============================================================================= +set -euo pipefail + +# 定位仓库根目录 +REPO_ROOT=$(git rev-parse --show-toplevel) +FE_TESTS_DIR="$REPO_ROOT/YLErpWeb/fe-tests" + +# 自动加载 nvm(非交互 shell 中 nvm 不会自动加载) +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ] && ! command -v node &>/dev/null; then + source "$NVM_DIR/nvm.sh" 2>/dev/null || true + # 如果有 .nvmrc 就用它,否则用默认版本 + if [ -f "$REPO_ROOT/.nvmrc" ]; then + nvm use --silent 2>/dev/null || true + else + # 尝试用已安装的最新版本 + nvm use --silent --lts 2>/dev/null || nvm use --silent node 2>/dev/null || true + fi +fi + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}[pre-commit] 开始提交前守卫检查...${NC}" + +# ----------------------------------------------------------------------------- +# 1. guard_arch.js — 架构闸门(零依赖,纯 Node) +# ----------------------------------------------------------------------------- +# 只在存在 node 时运行 +if command -v node &>/dev/null; then + echo -e "${YELLOW}[pre-commit] ① 架构闸门 (guard_arch.js)...${NC}" + if node "$FE_TESTS_DIR/guard_arch.js"; then + echo -e "${GREEN}[pre-commit] ✅ 架构闸门通过${NC}" + else + echo -e "${RED}[pre-commit] ❌ 架构闸门未通过,提交被阻断${NC}" + echo -e "${YELLOW} 请把金额/精度计算抽到 *Calc 模块(参考 swapCalc.js)${NC}" + exit 1 + fi +else + echo -e "${YELLOW}[pre-commit] ⚠️ 未找到 node,跳过架构闸门(建议安装 nvm + node 20)${NC}" +fi + +# ----------------------------------------------------------------------------- +# 2. jest — 前端单测(需要先 npm install) +# ----------------------------------------------------------------------------- +if [ -f "$FE_TESTS_DIR/node_modules/.bin/jest" ]; then + echo -e "${YELLOW}[pre-commit] ② 前端单测 (jest)...${NC}" + # 只在有 JS 源文件改动时才跑测试 + JS_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "*.js" | grep "wwwroot/Scripts/" || true) + if [ -z "$JS_CHANGED" ]; then + echo -e "${GREEN}[pre-commit] ⏭️ 无前端源文件改动,跳过单测${NC}" + else + echo -e "${YELLOW} 改动的前端文件:${NC}" + echo "$JS_CHANGED" | sed 's/^/ /' + if (cd "$FE_TESTS_DIR" && npx jest --no-coverage --silent 2>&1); then + echo -e "${GREEN}[pre-commit] ✅ 前端单测通过${NC}" + else + echo -e "${RED}[pre-commit] ❌ 前端单测未通过,提交被阻断${NC}" + echo -e "${YELLOW} 修复测试后重新提交,或用 git commit --no-verify 跳过(不推荐)${NC}" + exit 1 + fi + fi +else + echo -e "${YELLOW}[pre-commit] ⚠️ 未安装 jest(node_modules 缺失),跳过单测${NC}" + echo -e "${YELLOW} 建议:cd YLErpWeb/fe-tests && npm install${NC}" +fi + +# ----------------------------------------------------------------------------- +# 3. bundle 版本一致性 — 拦截"改了源文件却忘重新打 bundle" +# ----------------------------------------------------------------------------- +# EQD-6838 踩坑:8 次提交只为解决"改源文件没生效"。本检查在提交前用 +# rebuild-bundles.py --verify 逐字节比对产物与源文件,不同步则阻断。 +# 仅在有 bundle 相关改动时跑(Scripts 源文件 或 bundles 产物 任一改动)。 +if command -v python &>/dev/null || command -v python3 &>/dev/null; then + # 选可用的 python:优先 python(Windows 上 python3 可能是微软 Store 的 stub,不可用) + if command -v python &>/dev/null && python -c 'print(1)' &>/dev/null; then + PY=python + else + PY=python3 + fi + BUNDLE_SRC_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "*.js" | grep "wwwroot/Scripts/" || true) + BUNDLE_OUT_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "YLErpWeb/wwwroot/Statics/bundles/" || true) + if [ -n "$BUNDLE_SRC_CHANGED" ] || [ -n "$BUNDLE_OUT_CHANGED" ]; then + echo -e "${YELLOW}[pre-commit] ③ bundle 版本一致性 (rebuild-bundles.py --verify)...${NC}" + if ($PY "$REPO_ROOT/YLErpWeb/rebuild-bundles.py" --verify 2>&1); then + echo -e "${GREEN}[pre-commit] ✅ bundle 产物与源文件同步${NC}" + else + echo -e "${RED}[pre-commit] ❌ bundle 产物与源文件不同步,提交被阻断${NC}" + echo -e "${YELLOW} 请运行:python YLErpWeb/rebuild-bundles.py 重建并提交产物${NC}" + echo -e "${YELLOW} 或用 git commit --no-verify 跳过(不推荐)${NC}" + exit 1 + fi + else + echo -e "${GREEN}[pre-commit] ⏭️ 无 bundle 相关改动,跳过 bundle 校验${NC}" + fi +else + echo -e "${YELLOW}[pre-commit] ⚠️ 未找到 python,跳过 bundle 校验${NC}" +fi + +echo -e "${GREEN}[pre-commit] ✅ 所有守卫检查通过,继续提交${NC}" +exit 0 diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js new file mode 100644 index 00000000..d60ebdc6 --- /dev/null +++ b/YLErpWeb/fe-tests/jest.config.js @@ -0,0 +1,49 @@ +/** + * jest.config.js — 前端测试配置 + * ============================================================================ + * 运行方式:cd YLErpWeb/fe-tests && npx jest + * + * 设计要点: + * - testEnvironment: jsdom — 模拟浏览器环境(jQuery / Vue 组件测试需要) + * - testMatch: 只跑 *.test.js,不跑 _shim_run.js / guard_arch.js + * - collectCoverage: 默认不开(影响速度),用 --coverage 手动开启 + * - coverageThreshold: 新代码覆盖率门槛,逐步提高 + * - moduleDirectories: 让测试文件能 require('jquery') 等npm包 + * ============================================================================ + */ +module.exports = { + // 保持 node 环境:部分测试文件自行 require('jsdom') 搭建 DOM, + // 若改为 jsdom 环境会与之冲突(TextEncoder 未定义等问题)。 + testEnvironment: 'node', + + // 只匹配 *.test.js + testMatch: ['**/*.test.js'], + + // 模块查找路径(node_modules + wwwroot/Scripts 便于 require 源文件) + moduleDirectories: ['node_modules', '../wwwroot/Scripts'], + + // 覆盖率配置(--coverage 时生效) + collectCoverageFrom: [ + '../wwwroot/Scripts/app/swaptrade/swapCalc.js', + '../wwwroot/Scripts/fast/fastVue.base.js', + // 逐步加入更多文件 + ], + + coverageDirectory: 'coverage', + + // 覆盖率门槛(初始宽松,逐步收紧) + coverageThreshold: { + global: { + branches: 40, + functions: 50, + lines: 50, + statements: 50, + }, + }, + + // 不转换 node_modules(本项目 JS 是原生 ES5,不需要 babel) + transform: {}, + + // verbose 输出 + verbose: false, +}; diff --git a/YLErpWeb/fe-tests/main.postSafe.test.js b/YLErpWeb/fe-tests/main.postSafe.test.js new file mode 100644 index 00000000..9edbe534 --- /dev/null +++ b/YLErpWeb/fe-tests/main.postSafe.test.js @@ -0,0 +1,144 @@ +/** + * main.postSafe.test.js — main.postSafe() 错误守卫封装测试 + * ============================================================================ + * 目的:验证 postSafe 正确将 jQuery Deferred 的 .done/.fail 映射到 Promise 的 resolve/reject, + * 防止「业务错误走 reject、.done 不触发」陷阱复发。 + * + * 覆盖场景: + * 1. 成功请求 → Promise resolve + * 2. 业务错误(resp.success === false)→ Promise reject + * 3. 网络异常 → Promise reject + * 4. async/await 语法兼容性 + * 5. 与 main.post 行为对比(陷阱复现) + * + * 运行:cd YLErpWeb/fe-tests && npx jest main.postSafe + * ============================================================================ + */ + +// 搭建最小 DOM 环境 +const { JSDOM } = require('jsdom'); +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; +global.navigator = dom.window.navigator; + +// Mock main 对象(与 main.js 中的逻辑等价) +var main = {}; + +// main.post: 返回 jQuery Deferred promise,测试中通过 _lastDeferred 控制结果 +main.post = function (url, data, options) { + var doneCb = [], failCb = []; + var promise = { + done: function (cb) { doneCb.push(cb); return promise; }, + fail: function (cb) { failCb.push(cb); return promise; }, + }; + main._lastDeferred = { + resolve: function (resp) { doneCb.forEach(function (cb) { cb(resp); }); }, + reject: function (resp) { failCb.forEach(function (cb) { cb(resp); }); }, + promise: function () { return promise; }, + }; + return main._lastDeferred.promise(); +}; + +// main.postSafe: 与 main.js 中完全相同的实现 +main.postSafe = function (url, data, options) { + return new Promise(function (resolve, reject) { + main.post(url, data, options) + .done(function (resp) { resolve(resp); }) + .fail(function (resp) { reject(resp); }); + }); +}; + +describe('main.postSafe — Promise 封装守卫', function () { + + test('成功请求 → Promise resolve(resp)', function () { + var p = main.postSafe('/api/test', { foo: 1 }); + // postSafe 内部已调用 main.post,_lastDeferred 已就绪 + main._lastDeferred.resolve({ success: true, data: 'ok' }); + return p.then(function (resp) { + expect(resp.success).toBe(true); + expect(resp.data).toBe('ok'); + }); + }); + + test('业务错误(resp.success===false)→ Promise reject(resp)', function () { + var p = main.postSafe('/api/test', { foo: 1 }); + main._lastDeferred.reject({ success: false, msg: '算不出来' }); + return p.catch(function (resp) { + expect(resp.success).toBe(false); + expect(resp.msg).toBe('算不出来'); + }); + }); + + test('网络异常 → Promise reject', function () { + var p = main.postSafe('/api/test', { foo: 1 }); + main._lastDeferred.reject({ errcode: 500, msg: '请求失败' }); + return p.catch(function (resp) { + expect(resp.errcode).toBe(500); + }); + }); + + test('async/await 语法兼容 — 成功路径', async function () { + var p = main.postSafe('/api/test', { foo: 1 }); + main._lastDeferred.resolve({ success: true, value: 42 }); + var resp = await p; + expect(resp.success).toBe(true); + expect(resp.value).toBe(42); + }); + + test('async/await 语法兼容 — 失败路径', async function () { + var p = main.postSafe('/api/test', { foo: 1 }); + main._lastDeferred.reject({ success: false, msg: '计算失败' }); + + var caught = null; + try { + await p; + } catch (resp) { + caught = resp; + } + expect(caught).not.toBeNull(); + expect(caught.success).toBe(false); + expect(caught.msg).toBe('计算失败'); + }); +}); + +describe('main.postSafe — 与 main.post 行为对比(陷阱复现)', function () { + + test('main.post 的 .done 在业务错误时不触发(陷阱复现)', function () { + var doneCalled = false; + var failCalled = false; + + main.post('/api/test', {}).done(function () { + doneCalled = true; + }).fail(function () { + failCalled = true; + }); + + // 业务错误 → reject + main._lastDeferred.reject({ success: false }); + + expect(doneCalled).toBe(false); // .done 没触发!这就是陷阱 + expect(failCalled).toBe(true); // .fail 触发了 + }); + + test('main.postSafe 的 .then 在业务错误时不触发(安全)', function () { + var thenCalled = false; + var caught = null; + + var p = main.postSafe('/api/test', {}).then(function () { + thenCalled = true; + }).catch(function (resp) { + caught = resp; + }); + + // 业务错误 → reject + main._lastDeferred.reject({ success: false, msg: 'biz error' }); + + // Promise 是微任务,需要等一轮 + return p.then(function () { + expect(thenCalled).toBe(false); // .then 没触发 + expect(caught).not.toBeNull(); // .catch 触发了 + expect(caught.msg).toBe('biz error'); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/run-ci-checks.sh b/YLErpWeb/fe-tests/run-ci-checks.sh new file mode 100755 index 00000000..b8c290fa --- /dev/null +++ b/YLErpWeb/fe-tests/run-ci-checks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# ============================================================================= +# run-ci-checks.sh — CI 守卫入口脚本 +# ============================================================================= +# Jenkins / GitLab CI / GitHub Actions 等任一 CI 系统调用本脚本即可。 +# +# 做三件事(任一失败即 exit 1,阻断 CI): +# 1. guard_arch.js — 架构闸门:禁止新增内联金额计算 +# 2. jest — 前端单测:防止回归 +# 3. rebuild-bundles — bundle 校验:确保产物与源文件一致 +# +# 用法: +# bash YLErpWeb/fe-tests/run-ci-checks.sh +# +# 前置条件: +# - Node.js 18+ 已安装(CI 环境通常自带) +# - cd YLErpWeb/fe-tests && npm install 已执行 +# ============================================================================= +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +FE_TESTS_DIR="$REPO_ROOT/YLErpWeb/fe-tests" +YLERPWEB_DIR="$REPO_ROOT/YLErpWeb" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +FAILED=0 + +run_check() { + local name="$1" + local cmd="$2" + echo -e "\n${YELLOW}========================================${NC}" + echo -e "${YELLOW}CI Check: ${name}${NC}" + echo -e "${YELLOW}========================================${NC}" + if eval "$cmd"; then + echo -e "${GREEN}✅ ${name} PASSED${NC}" + else + echo -e "${RED}❌ ${name} FAILED${NC}" + FAILED=$((FAILED + 1)) + fi +} + +# ----------------------------------------------------------------------------- +# Check 1: 架构闸门 +# ----------------------------------------------------------------------------- +if command -v node &>/dev/null; then + run_check "guard_arch.js (架构闸门)" \ + "node '$FE_TESTS_DIR/guard_arch.js'" +else + echo -e "${YELLOW}⚠️ node 未安装,跳过架构闸门${NC}" +fi + +# ----------------------------------------------------------------------------- +# Check 2: 前端单测 +# ----------------------------------------------------------------------------- +if [ -f "$FE_TESTS_DIR/node_modules/.bin/jest" ]; then + run_check "jest (前端单测)" \ + "cd '$FE_TESTS_DIR' && npx jest --no-coverage --silent" +else + echo -e "${YELLOW}⚠️ jest 未安装,尝试自动安装...${NC}" + if command -v npm &>/dev/null; then + (cd "$FE_TESTS_DIR" && npm install --silent) + run_check "jest (前端单测)" \ + "cd '$FE_TESTS_DIR' && npx jest --no-coverage --silent" + else + echo -e "${RED}❌ npm 不可用,无法运行前端单测${NC}" + FAILED=$((FAILED + 1)) + fi +fi + +# ----------------------------------------------------------------------------- +# Check 3: Bundle 校验(可选,需要 python3) +# ----------------------------------------------------------------------------- +if [ -f "$YLERPWEB_DIR/rebuild-bundles.py" ] && command -v python3 &>/dev/null; then + run_check "bundle 校验 (rebuild-bundles.py --verify)" \ + "cd '$YLERPWEB_DIR' && python3 rebuild-bundles.py --verify" +else + echo -e "${YELLOW}⚠️ python3 或 rebuild-bundles.py 不可用,跳过 bundle 校验${NC}" +fi + +# ----------------------------------------------------------------------------- +# 汇总 +# ----------------------------------------------------------------------------- +echo -e "\n${YELLOW}========================================${NC}" +echo -e "${YELLOW}CI Checks Summary${NC}" +echo -e "${YELLOW}========================================${NC}" +if [ "$FAILED" -eq 0 ]; then + echo -e "${GREEN}✅ All checks passed${NC}" + exit 0 +else + echo -e "${RED}❌ ${FAILED} check(s) failed${NC}" + exit 1 +fi diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 9dac27b6..87c6e17d 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -293,79 +293,77 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', }); // ============================================================================ -// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券 -// 旧 bug:setUnderlyingCode 切债券时未重置 bondManual/bondDriverType, -// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。 +// D1 回归守卫:切换债券标的必须清空 源/AUTO/REV 标志,避免旧债券状态污染新债券 +// 旧 bug:setUnderlyingCode 切债券时未重置状态,旧债券标记过的字段在新债券上会被错误沿用。 // 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。 // ============================================================================ -describe('D1 切换标的清空债券互算手动/源标志', () => { - test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => { +describe('D1 切换标的清空债券互算源/AUTO/REV 标志', () => { + test('clearBondCalcFlags 把 bondDriverType/bondAuto/bondRev 全清', () => { const item = { - bondManual: { CP: true, DP: false, YD: true }, - bondDriverType: 'YD' + bondDriverType: 'YD', + bondAuto: { CP: true, DP: true, YD: false }, + bondRev: { CP: false, DP: false, YD: true } }; const out = SwapCalc.clearBondCalcFlags(item); - expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); expect(out.bondDriverType).toBeNull(); + expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false }); }); test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => { const item = { UnderlyingCode: '200000.IB', isBond: true }; const out = SwapCalc.clearBondCalcFlags(item); - expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); expect(out.bondDriverType).toBeNull(); + expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false }); expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响 }); - test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => { - // 债券 A:用户手填了全价,标记手动 + 设源 + test('模拟"债券A回车→切债券B":B 不应继承 A 的源/AUTO/REV', () => { + // 债券 A:用户回车了全价,标记源 + AUTO const item = { UnderlyingCode: '190000.IB', isBond: true, - bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' }; + bondDriverType: 'DP', + bondAuto: { CP: true, DP: false, YD: true }, + bondRev: { CP: false, DP: false, YD: false } }; // 切到债券 B(setUnderlyingCode 会调 clearBondCalcFlags) SwapCalc.clearBondCalcFlags(item); item.UnderlyingCode = '200000.IB'; - // 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误 - const manual = item.bondManual; - expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志 - expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源 + expect(item.bondDriverType).toBeNull(); // B 无计算源 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false }); }); }); // ============================================================================ -// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志 -// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 -// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual -// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算。 +// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算标识随页面重置丢失 → +// 加载路径调 clearBondCalcMarksOnLoad 把 源/AUTO/REV 全部清空,纯展示保存值、无锁定。 +// 交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格; +// 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。 // ============================================================================ -describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => { - test('markExistingBondManual 把三字段全锁 true、不置计算源', () => { - const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; - const out = SwapCalc.markExistingBondManual(item); - expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true }); - expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导 +describe('D2 重开已保存债券单 → 三字段无标识、编辑不联动另两格', () => { + test('clearBondCalcMarksOnLoad 把 源/AUTO/REV 全清、不置计算源、不动价格值', () => { + const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, + bondDriverType: 'CP', + bondAuto: { CP: false, DP: true, YD: true }, + bondRev: { CP: false, DP: false, YD: false } }; + const out = SwapCalc.clearBondCalcMarksOnLoad(item); + expect(out.bondDriverType).toBeNull(); + expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false }); + expect(out.PosiNetNoFeePrice).toBe(0.995); // 价格值本身不动,仅清标识 + expect(out.PosiGrossPrice).toBe(1.0); }); - test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => { + test('重开后仅编辑某一格(未回车):交互约定3 使另两格不被反算覆盖', () => { const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; - SwapCalc.markExistingBondManual(item); - // 模拟用户编辑全价(DP):onBondPriceInput 会置 bondDriverType='DP' 并重算 - item.bondDriverType = 'DP'; - const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 }; - const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 }; - SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); - // 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留 - expect(proxy.cleanPrice).toBe(99.5); - expect(proxy.dirtyPrice).toBe(100.0); - expect(proxy.ytm).toBe(6.37); - }); - - test('加载路径不会误锁"全新未填的债券"(三值不全)', () => { - // 全新债券标的,价格字段空 → 不应被标记为手动锁定 - const item = { isBond: true, PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null }; - SwapCalc.markExistingBondManual(item); - // 全新标的本不应调用 markExistingBondManual;此处断言:即便误调也不应制造假锁定干扰后续交互 - // (实际加载逻辑用 hasV 三值齐全判定,只在已保存单上调用,此用例验证函数本身不副作用其它字段) - expect(item.UnderlyingCode).toBeUndefined(); + SwapCalc.clearBondCalcMarksOnLoad(item); + // 模拟用户编辑全价(DP)但未回车:onBondPriceEdit 调 applyBondManualEdit → 清源/AUTO、DP 标 REV,不调计算器 + SwapCalc.applyBondManualEdit(item, 'DP'); + expect(item.bondDriverType).toBeNull(); // 无计算源 → 不会反算 + expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false }); + expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false }); + expect(item.PosiNetNoFeePrice).toBe(0.995); // 另两格原值保留 + expect(item.InitYtm).toBe(0.026); }); }); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index b977b41e..53c8197d 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -90,3 +90,41 @@ describe('unwindSwapTrade 基础费率计算', () => { expectClose(result, 200.00); }); }); + +describe('base-rate pending trading fee', () => { + const { swapPosiFeeCalc, consPosiFeeType } = loadUnwindHelpers(); + + test('unit rate follows actual close quantity instead of an inconsistent close percent', () => { + const result = swapPosiFeeCalc.calcTradingFeePending( + 2000, consPosiFeeType.Unit, 0.2, 4200, 3000, 10000, 10000, 0.42); + expectClose(result, 600.00); + }); + + test('percentage rate follows actual close notional value', () => { + const result = swapPosiFeeCalc.calcTradingFeePending( + 2000, consPosiFeeType.Percent, 0.2, 300000, 4200, 1000000, 10000, 0.42); + expectClose(result, 600.00); + }); + + test('a manually adjusted original pending fee is allocated by actual close quantity', () => { + const result = swapPosiFeeCalc.calcTradingFeePending( + 1500, consPosiFeeType.Unit, 0.2, 4200, 3000, 10000, 10000, 0.42); + expectClose(result, 450.00); + }); + + test('partial close fee and pending fee both use the rounded opening fee allocation', () => { + const tradingFee = swapPosiFeeCalc.calcAllocatedTradingFee( + 113.46, consPosiFeeType.Percent, 1.1234, 4039.2, 4000, 10098, 10000); + const pendingFee = swapPosiFeeCalc.calcTradingFeePending( + 113.46, consPosiFeeType.Percent, 1.1234, 4039.2, 4000, 10098, 10000, 0.4); + + expectClose(tradingFee, 45.38); + expectClose(pendingFee, 45.38); + }); + + test('without a configured base rate, the legacy close-percent calculation remains', () => { + const result = swapPosiFeeCalc.calcTradingFeePending( + 2000, consPosiFeeType.Percent, 0, 300000, 3000, 1000000, 10000, 0.42); + expectClose(result, 840.00); + }); +}); diff --git a/YLErpWeb/rebuild-bundles.ps1 b/YLErpWeb/rebuild-bundles.ps1 new file mode 100644 index 00000000..6f637b16 --- /dev/null +++ b/YLErpWeb/rebuild-bundles.ps1 @@ -0,0 +1,169 @@ +<# +.SYNOPSIS + Rebuild all bundle artifacts by concatenating inputs per bundleconfig.json (strip BOM, no minify). +.DESCRIPTION + No dependency on BuildBundlerMinifier. Deterministic pure concatenation: + 1. Read bundleconfig.json + 2. For each bundle: read input files in order, strip leading UTF-8 BOM, join with CRLF + 3. Only write out minify.enabled=false artifacts (overwrite committed files) + For minify.enabled=true artifacts (minified CSS), skip generation (requires tooling) + Output is byte-identical to BuildBundlerMinifier (SHA256 verified). +.PARAMETER Verify + Verify-only mode: compare generated content with committed files, report mismatches, do NOT modify files. +.EXAMPLE + .\rebuild-bundles.ps1 # Rebuild and overwrite committed bundle artifacts + .\rebuild-bundles.ps1 -Verify # Verify only, do not modify files +#> +[CmdletBinding()] +param( + [switch]$Verify +) + +$ErrorActionPreference = 'Stop' +$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$configPath = Join-Path $projectDir 'bundleconfig.json' + +if (-not (Test-Path $configPath)) { + throw "bundleconfig.json not found: $configPath" +} + +# Read bundleconfig.json (strip // comments) +$configJson = Get-Content $configPath -Raw +$configJson = $configJson -replace '(?m)//.*$', '' +$bundles = $configJson | ConvertFrom-Json + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) +$mismatches = @() + +# CI 环境检测:CI 下 git checkout 后所有文件 mtime 几乎相同,增量检查不可靠 +# 因此 CI 环境强制全量重建,本地开发才走增量 +$isCI = -not [string]::IsNullOrEmpty($env:CI) ` + -or -not [string]::IsNullOrEmpty($env:JENKINS_HOME) ` + -or -not [string]::IsNullOrEmpty($env:BUILD_NUMBER) ` + -or -not [string]::IsNullOrEmpty($env:GITHUB_ACTIONS) + +foreach ($bundle in $bundles) { + $outputRel = $bundle.outputFileName + $outputPath = Join-Path $projectDir $outputRel + $isMinified = $bundle.minify.enabled + + # 增量检查:非 Verify、非 CI、非 minified,且 bundle 产物已存在时, + # 若所有源文件都不比 bundle 产物新,则跳过此 bundle + # 目的:让 target 挂到 Build 之前时,后端-only 改动不会触发重建,IDE F5 不变慢 + # CI 环境强制全量:git checkout 后 mtime 不可靠,必须重建保证部署产物正确 + if (-not $Verify -and -not $isCI -and -not $isMinified -and (Test-Path $outputPath)) { + $outputMtime = (Get-Item $outputPath).LastWriteTime + $stale = $false + foreach ($inputRel in $bundle.inputFiles) { + $inputPath = Join-Path $projectDir $inputRel + if ((Test-Path $inputPath) -and (Get-Item $inputPath).LastWriteTime -gt $outputMtime) { + $stale = $true + break + } + } + if (-not $stale) { + Write-Host ("[UP-TO-DATE] {0}" -f $outputRel) -ForegroundColor DarkGray + continue + } + } + + Write-Host ("[{0}] {1}" -f $(if ($isMinified) { 'SKIP-MIN' } else { 'BUILD' }), $outputRel) + + # Check input files exist + no BOM + $parts = [System.Collections.Generic.List[string]]::new() + $missingInputs = @() + $bomInputs = @() + + foreach ($inputRel in $bundle.inputFiles) { + $inputPath = Join-Path $projectDir $inputRel + if (-not (Test-Path $inputPath)) { + $missingInputs += $inputRel + continue + } + # Detect BOM by reading raw bytes + $bytes = [System.IO.File]::ReadAllBytes($inputPath) + $hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) + if ($hasBom) { + $bomInputs += $inputRel + } + # Read text (UTF8, BOM auto-stripped by ReadAllText) + $text = [System.IO.File]::ReadAllText($inputPath, [System.Text.Encoding]::UTF8) + # Normalize CRLF to LF (bundle artifacts use LF in git repo) + $text = $text -replace "`r`n", "`n" + $parts.Add($text) + } + + if ($missingInputs.Count -gt 0) { + # 缺失输入文件:Verify 模式报错;重建模式只警告不 fail,避免阻塞 CI 主构建 + # 重建模式下跳过此 bundle(保留已提交的产物) + $level = $(if ($Verify) { 'ERROR' } else { 'WARN' }) + $color = $(if ($Verify) { 'Red' } else { 'Yellow' }) + Write-Host " [$level] Missing input files:" -ForegroundColor $color + foreach ($m in $missingInputs) { Write-Host " - $m" } + if ($Verify) { + $mismatches += "$outputRel : missing inputs ($($missingInputs -join ', '))" + } + continue + } + if ($bomInputs.Count -gt 0) { + # Verify 模式:BOM 是源文件问题,报错阻止提交 + # 重建模式:BOM 会在读取时自动剥离(ReadAllText 处理),只警告不 fail,避免阻塞 CI 部署 + $level = $(if ($Verify) { 'ERROR' } else { 'WARN' }) + $color = $(if ($Verify) { 'Red' } else { 'Yellow' }) + Write-Host " [$level] Input files have BOM (will be stripped on rebuild):" -ForegroundColor $color + foreach ($b in $bomInputs) { Write-Host " - $b" } + if ($Verify) { + $mismatches += "$outputRel : input BOM ($($bomInputs -join ', '))" + } + } + + # Pure concatenation with LF separator between files (matches committed bundle artifacts) + $content = [string]::Join("`n", $parts) + + # For minified artifacts, only report, do not write + if ($isMinified) { + Write-Host " (minified artifact, skip write, only input check)" -ForegroundColor Yellow + continue + } + + if ($Verify) { + # Verify mode: compare with committed file + if (-not (Test-Path $outputPath)) { + Write-Host " [ERROR] Output file not found: $outputRel" -ForegroundColor Red + $mismatches += "$outputRel : output file not found" + continue + } + $existing = [System.IO.File]::ReadAllText($outputPath, [System.Text.Encoding]::UTF8) + if ($existing -ne $content) { + Write-Host " [MISMATCH] Differs from committed version" -ForegroundColor Red + $mismatches += "$outputRel : out of sync (source changed but bundle not rebuilt)" + } else { + Write-Host " [OK] Matches committed version" -ForegroundColor Green + } + } else { + # Rebuild mode: write (no BOM) + $dir = Split-Path $outputPath -Parent + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + [System.IO.File]::WriteAllText($outputPath, $content, $utf8NoBom) + Write-Host " [DONE] Written ($($content.Length) chars)" -ForegroundColor Green + } +} + +Write-Host "" +if ($mismatches.Count -gt 0) { + # Verify 模式:有问题则 fail(阻止提交不规范的状态) + # 重建模式:永远不 fail,避免阻塞 CI 主构建(缺失/BOM 等已在循环内警告并跳过) + if ($Verify) { + Write-Host "=== Result: ISSUES FOUND (Verify mode) ===" -ForegroundColor Red + foreach ($m in $mismatches) { Write-Host " - $m" } + exit 1 + } else { + Write-Host "=== Result: COMPLETED WITH WARNINGS (Rebuild mode) ===" -ForegroundColor Yellow + foreach ($m in $mismatches) { Write-Host " - $m" } + Write-Host " (warnings do not block build in rebuild mode)" -ForegroundColor DarkGray + exit 0 + } +} else { + Write-Host "=== Result: ALL PASSED ===" -ForegroundColor Green + exit 0 +} diff --git a/YLErpWeb/rebuild-bundles.py b/YLErpWeb/rebuild-bundles.py new file mode 100644 index 00000000..89492d18 --- /dev/null +++ b/YLErpWeb/rebuild-bundles.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +rebuild-bundles.py —— 前端 bundle 产物的确定性重建脚本 + +为什么需要它: + 页面加载的是 bundle.js / jquery.js / vue.js / bundle.css / bundleV2.css / bundleV2.js + (见 _MainLayout.cshtml),这些是由 bundleconfig.json 把分散的源文件拼接而成。 + 历史上"改了源文件却忘了重新打 bundle"导致部署的仍是陈旧/残缺产物(本次 EQD-6838 的痛点)。 + 本脚本按与历史手工打包一致的规则确定性地重建这 6 个产物,使"改源 -> 重新打 bundle"可复现、可校验。 + +拼接规则(已与已提交版本逐字节比对验证一致): + 1. 每个输入文件:剥掉文件首的 UTF-8 BOM(EF BB BF),行尾 CRLF->LF 归一(不改文件原始编码,GBK 等字节原样保留)。 + 2. 输入文件之间用一个 '\\n' 分隔(末尾文件后不加分隔符)。 + 3. CSS 产物额外做相对 url() 重写:把相对于各 CSS 源文件目录的 url(...) 改写为相对于 + bundle 输出目录(Statics/bundles/)的路径,行为与 BuildBundlerMinifier 一致: + - data: URI 原样保留(含引号); + - 其余 url 保留原始引号风格,仅改写路径为相对 bundle 目录的相对路径。 + (注:已提交 bundle.css 中少量 GBK 中文注释曾被旧工具误解码成 UTF-8 替换符,本脚本生成的是正确字节; + 重建后这些注释乱码会被修正,仅影响注释,不影响渲染。) + +用法: + python3 rebuild-bundles.py # 重建并写回 wwwroot/Statics/bundles/ 下 6 个产物 + python3 rebuild-bundles.py --verify # 重建到内存并与已提交文件比对,不一致则 exit 1(供 CI 校验) +""" +import json +import os +import re +import sys + +BOM = b'\xef\xbb\xbf' +ROOT = os.path.dirname(os.path.abspath(__file__)) # YLErpWeb 目录 +# 引号优先匹配,避免 data: URI 内的 ) 截断 +URL_RE = re.compile(rb"url\(\s*('[^']*'|\"[^\"]*\"|[^\"'()]*)\s*\)") + + +def normalize(b): + """剥文件首 BOM(若有) + 行尾归一为 \\n。全程字节级,不改编码(GBK 等原样保留)。""" + if isinstance(b, bytes): + if b.startswith(BOM): + b = b[3:] + return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + + +def rewrite_css_urls(css, input_rel, out_rel): + """CSS 相对 url() 改写为相对 bundle 输出目录(与 BuildBundlerMinifier 行为一致)。""" + base = os.path.dirname(input_rel) # 输入文件目录(相对 ROOT) + outdir = os.path.dirname(out_rel) # bundle 输出目录(相对 ROOT) + + def repl(m): + raw = m.group(1).strip() + # 保留 url() 内原始引号风格 + if len(raw) >= 2 and raw[0:1] == raw[-1:] and raw[0:1] in (b"'", b'"'): + q = raw[0:1] + inner = raw[1:-1].strip() + else: + q = b'' + inner = raw + if inner.startswith(b'data:'): + return m.group(0) # data: URI 原样保留(含引号) + if re.match(rb'^(https?:|//|#)', inner) or inner.startswith(b'/'): + return m.group(0) + try: + udec = inner.decode('ascii') # url 通常为 ascii + except Exception: + return m.group(0) + resolved = os.path.normpath(os.path.join(base, udec)) + rel = os.path.relpath(resolved, outdir).replace(os.sep, '/') + return b'url(' + q + rel.encode('utf-8', errors='surrogateescape') + q + b')' + return URL_RE.sub(repl, css) + + +def build_all(): + cfg_path = os.path.join(ROOT, 'bundleconfig.json') + raw = open(cfg_path, 'rb').read() + raw = re.sub(rb'//[^\n]*', b'', raw) # 去掉 // 行注释 + cfg = json.loads(raw.decode('utf-8-sig')) + + results = [] # (out, generated_bytes, missing_list) + for b in cfg: + out = b['outputFileName'] + iscss = out.endswith('.css') + parts = [] + missing = [] + for f in b['inputFiles']: + p = os.path.join(ROOT, f) + if not os.path.exists(p): + # ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js; + # 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的 + # 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。 + # 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。 + missing.append(f) + continue + content = normalize(open(p, 'rb').read()) + if iscss: + content = rewrite_css_urls(content, f, out) + parts.append(content) + joined = b'\n'.join(parts) # 文件间一个 \n 分隔,末尾不加 + results.append((out, joined, missing)) + return results + + +def is_stale(out, input_files): + """增量检查:任一源文件比 bundle 产物新(或产物不存在)则返回 True。""" + dest = os.path.join(ROOT, out) + if not os.path.exists(dest): + return True + out_mtime = os.path.getmtime(dest) + for f in input_files: + p = os.path.join(ROOT, f) + if os.path.exists(p) and os.path.getmtime(p) > out_mtime: + return True + return False + + +def is_ci_environment(): + """检测是否在 CI 环境运行。 + CI 下 git checkout 后所有文件 mtime 几乎相同,增量检查不可靠, + 因此 CI 环境强制全量重建。 + """ + ci_vars = ('CI', 'JENKINS_HOME', 'BUILD_NUMBER', 'GITHUB_ACTIONS', + 'GITLAB_CI', 'TF_BUILD') + return any(os.environ.get(v) for v in ci_vars) + + +def main(): + verify = '--verify' in sys.argv[1:] + force_rebuild = is_ci_environment() # CI 环境强制全量重建 + # 读取配置用于增量检查 + cfg_path = os.path.join(ROOT, 'bundleconfig.json') + raw = open(cfg_path, 'rb').read() + raw = re.sub(rb'//[^\n]*', b'', raw) + cfg = json.loads(raw.decode('utf-8-sig')) + + # 增量过滤:非 verify、非 CI 时,跳过源文件未变更的 bundle + # CI 环境(force_rebuild=True)强制全量重建,因为 git checkout 后 mtime 不可靠 + stale_bundles = [] + for b in cfg: + out = b['outputFileName'] + if verify or force_rebuild or is_stale(out, b['inputFiles']): + stale_bundles.append(b) + else: + print(f" [UP-TO-DATE] {out}") + + # 只重建过期的 bundle + results = [] + for b in stale_bundles: + out = b['outputFileName'] + iscss = out.endswith('.css') + parts = [] + missing = [] + for f in b['inputFiles']: + p = os.path.join(ROOT, f) + if not os.path.exists(p): + # ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js; + # 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的 + # 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。 + # 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。 + missing.append(f) + continue + content = normalize(open(p, 'rb').read()) + if iscss: + content = rewrite_css_urls(content, f, out) + parts.append(content) + joined = b'\n'.join(parts) + results.append((out, joined, missing)) + + ok = True + for out, joined, missing in results: + dest = os.path.join(ROOT, out) + if missing: + # ⚠️【CI 踩坑必读 / 非致命】缺失输入文件绝不 fail:早期实现把 MISSING 当致命错误 exit 1, + # 会连带打挂整个 dotnet publish(yml 报错 "命令已退出,代码为 1")。 + # 现改为:重建模式跳过该 bundle(保留已提交的产物即可,CI 仍能出包);只有 --verify 校验模式才 exit 1。 + # 重建模式下警告但不 fail,避免阻塞 CI 主构建 + if verify: + ok = False + print(f" [MISSING INPUT] {out}: {missing}") + else: + print(f" [SKIP-MISSING] {out}: 输入缺失 {missing},保留已提交产物") + continue + if verify: + original = normalize(open(dest, 'rb').read()) + if joined != original: + ok = False + n = min(len(joined), len(original)) + i = 0 + while i < n and joined[i] == original[i]: + i += 1 + print(f" [STALE] {out} 与源文件不同步 @byte {i}") + print(f" gen={joined[max(0,i-40):i+40]!r}") + print(f" com={original[max(0,i-40):i+40]!r}") + else: + open(dest, 'wb').write(joined) + print(f"{'VERIFY ' if verify else 'WRITE '} -> {out} ({len(joined)} bytes)" + + ("" if not missing else f" MISSING={missing}")) + if verify: + print("\n全部与源同步 ✅" if ok else "\n存在不同步,请先运行 `python3 rebuild-bundles.py` 重建并提交 ✅") + else: + print("\n重建完成" if ok else "\n重建完成(部分 bundle 输入缺失已跳过,请检查)") + # 重建模式永远返回 0,不阻塞 CI;Verify 模式才可能返回 1 + return 0 if (ok or not verify) else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/YLErpWeb/rebuild-bundles.sh b/YLErpWeb/rebuild-bundles.sh new file mode 100644 index 00000000..e8d18d0b --- /dev/null +++ b/YLErpWeb/rebuild-bundles.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# rebuild-bundles.sh —— 委托给 rebuild-bundles.py 重建/校验前端 bundle 产物。 +# CI(Linux) 在 publish 前由 YLErpWeb.csproj 的 GenerateBundlesBeforePublish 目标调用。 +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +if command -v python3 >/dev/null 2>&1; then + exec python3 "$DIR/rebuild-bundles.py" "$@" +else + echo "WARNING: python3 不可用,跳过 bundle 重建(将使用仓库内已提交的产物)。" >&2 + exit 0 +fi diff --git a/YLErpWeb/wwwroot/Scripts/MyJs.js b/YLErpWeb/wwwroot/Scripts/MyJs.js index 67d1ee4c..a8d98099 100644 --- a/YLErpWeb/wwwroot/Scripts/MyJs.js +++ b/YLErpWeb/wwwroot/Scripts/MyJs.js @@ -1,4 +1,4 @@ -$(function () { +$(function () { try { $.datepicker._gotoToday = function (id) { $(id).datepicker('setDate', new Date()).datepicker('hide').blur(); diff --git a/YLErpWeb/wwwroot/Scripts/app/base/jqGridEx.js b/YLErpWeb/wwwroot/Scripts/app/base/jqGridEx.js index e022759a..99802f18 100644 --- a/YLErpWeb/wwwroot/Scripts/app/base/jqGridEx.js +++ b/YLErpWeb/wwwroot/Scripts/app/base/jqGridEx.js @@ -1,4 +1,4 @@ -var main = main || {}; +var main = main || {}; (function ($) { diff --git a/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js b/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js index 02f7f83d..c351e43c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/risk/SubstandardTrade.js @@ -246,7 +246,7 @@ function getColModelDefault() { width: 85, align: 'center', sortable: false, - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'TradeType2', @@ -374,7 +374,7 @@ function getColModelDefault() { width: 65, align: 'center', sortable: false, - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }); return tradeHelper.getAmountToNotional(colModelGrid); @@ -388,14 +388,6 @@ function ShowStructFormater(cellValue, options, rowObject) { return cellValue || ''; } -function EventTwoDecimalFormat(cellValue) { - if (cellValue === null || cellValue === undefined || cellValue === '') { - return ''; - } - var price = Number(cellValue); - return isFinite(price) ? price.toFixed(2) : cellValue; -} - function StrikeFormatter(cellValue, options, rowObject) { if (rowObject.TradeType === "自定义交易") { return ""; @@ -404,12 +396,20 @@ function StrikeFormatter(cellValue, options, rowObject) { return "--"; } - if (cellValue === null || cellValue === undefined || cellValue === '' || cellValue === 'NaN') return ''; - var strike = Number(cellValue); - if (!isFinite(strike)) return ''; - return rowObject.IsMoneynessOption === "是" - ? (strike * 100).toFixed(2) + '%' - : EventTwoDecimalFormat(strike); + if (rowObject.Strike) { + if (rowObject.IsMoneynessOption === "是") { + return otcformat.trading.premiumRateP(rowObject.Strike); + } else { + return otcformat.trading.umprice(rowObject.Strike); + } + } else { + if (rowObject.Strike === 0) { + return otcformat.trading.umprice(0); + } + else { + return ""; + } + } } function CommissionFormatter(cellValue, options, rowObject) { @@ -651,4 +651,4 @@ function showcolumnChooser() { function getColModel() { return getColModelDefault(); -} +} \ No newline at end of file diff --git a/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js b/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js index 4d6328a9..4c159554 100644 --- a/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js +++ b/YLErpWeb/wwwroot/Scripts/app/risk/quotaMonitor.js @@ -361,16 +361,6 @@ function formatQuotaAbs(obj, fieldName) { return html; } -function formatFixedTwoDecimals(cellvalue) { - if (cellvalue === null || cellvalue === undefined || cellvalue === '' || cellvalue === 'NaN') { - return ''; - } - var numberValue = Number(cellvalue); - return isFinite(numberValue) - ? numberValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - : ''; -} - function formatQuotaRef(obj, fieldName) { var html = ""; var upperValue = obj["Quota_" + fieldName + "_Upper"]; @@ -554,21 +544,8 @@ function quotaMonitorUploadForm(url) { } function Output() { - var summaryType = $('#myTab .active a').text(); - var fileName = new moment().format("YYYYMMDD") + summaryType; - var formatters = null; - if (summaryType === '整体业务汇总' || summaryType === '标的汇总' || summaryType === '客户汇总') { - formatters = [{ - colName: '名义本金', - formatter: function (cellvalue, options, rowObject) { - if (summaryType === '整体业务汇总' && rowObject.BusinessType === '场内业务') { - return ''; - } - return formatFixedTwoDecimals(cellvalue); - } - }]; - } - main.toExcel("listGrid", fileName, "xls", null, [" ", "操作"], formatters); + var fileName = new moment().format("YYYYMMDD") + $('#myTab .active a').text(); + main.toExcel("listGrid", fileName, "xls", null, [" ", "操作"]); } function confirmAllSelect() { @@ -1113,7 +1090,7 @@ var colModel_undelrying = [ align: 'right', sortable: false, formatter: function (cellvalue, options, rowObject) { - return formatFixedTwoDecimals(cellvalue); + return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); }, cellattr: function (cellvalue, options, rowObject) { var style = checkQuota(rowObject, 'StockEqvNotional'); @@ -1530,7 +1507,7 @@ var colModel_client = [ align: 'right', sortable: false, formatter: function (cellvalue, options, rowObject) { - return formatFixedTwoDecimals(cellvalue); + return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); }, cellattr: function (cellvalue, options, rowObject) { var style = "style='" + checkQuota(rowObject, 'StockEqvNotional') + "'"; @@ -1681,7 +1658,10 @@ var colModel_global = [ if (rowObject.BusinessType == "场内业务") { return '
'; } - return formatFixedTwoDecimals(cellvalue); + if (page.IsGuoXin && cellvalue != null) { + return cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); + } + return !cellvalue || cellvalue == "NaN" ? "" : cellvalue.toLocaleString(); }, cellattr: function (cellvalue, options, rowObject) { var style = checkQuota(rowObject, 'StockEqvNotional'); @@ -2152,4 +2132,4 @@ var colModel_Log = [ align: 'center', sortable: false } -]; +]; \ No newline at end of file diff --git a/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js b/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js index c30a7782..ccdd4c86 100644 --- a/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js +++ b/YLErpWeb/wwwroot/Scripts/app/scenario/tradeScenario.js @@ -13,12 +13,20 @@ const colModelGrid = (function () { } function StrikeFormatter(cellValue, options, rowObject) { - if (cellValue === null || cellValue === undefined || cellValue === '' || cellValue === 'NaN') return ''; - var strike = Number(cellValue); - if (!isFinite(strike)) return ''; - return rowObject.IsMoneynessOption === "是" - ? (strike * 100).toFixed(2) + '%' - : strike.toFixed(2); + if (rowObject.Strike) { + if (rowObject.IsMoneynessOption === "是") { + return otcformat.trading.premiumRateP(rowObject.Strike); + } else { + return otcformat.trading.umprice(rowObject.Strike); + } + } else { + if (rowObject.Strike === 0) { + return 0; + } + else { + return ""; + } + } } var col = [ @@ -265,4 +273,4 @@ function setTestValue(list) { testStstus = false; main.alert("计算完成"); } -} +} \ No newline at end of file diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js index ca12a59f..8d799ab1 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/EodPositionRisks.js @@ -136,20 +136,14 @@ function colModelGridEodPosition() { align: 'center', sortIndex: i++, sortable: true - }, { - name: 'eodPosition.PosiDirection', - label: '[浮动]收支方向', - index: 'eodPosition.PosiDirection', - width: 90, - align: 'center', - formatter: DrictionFormat }, { name: 'eodPosition.PositionType', - label: '标的多空(B/S)', + label: '交易方向', index: 'eodPosition.PositionType', width: 90, align: 'center', - formatter: PositionTypeFormat + formatter: TradeDirectionFormat, + sortable: false }, { name: 'eodPosition.UnderlyingCode', label: '标的代码', @@ -168,9 +162,7 @@ function colModelGridEodPosition() { index: 'eodPosition.PosiNetPrice', width: 90, align: 'center', - formatter: PriceFormat, - exportFormatter: ExportPriceNineDecimalFormat, - exportNumberFormat: '0.000000000' + formatter: PriceFormat }, { name: 'eodPosition.PosiGrossPrice', label: '期初价格-不含费', @@ -178,8 +170,6 @@ function colModelGridEodPosition() { width: 90, align: 'center', formatter: PriceFormat, - exportFormatter: ExportPriceNineDecimalFormat, - exportNumberFormat: '0.000000000' }, { name: 'eodPosition.PosiQuantity', label: '名义数量', @@ -747,7 +737,7 @@ function exportVisibleColumns() { var tabName = page.tabIndex == 2 ? '框架合约' : '日终持仓'; var fileName = '日终持仓风险_互换_' + tabName + (dateStr ? '_' + dateStr : ''); if (page.tabIndex != 2) { - exportEodPositionRows(jgrid, fileName); + main.exportVisibleColumnsToExcel(jgrid, fileName, null); return; } @@ -774,26 +764,6 @@ function exportVisibleColumns() { }); } -function exportEodPositionRows(jgrid, fileName) { - var exportPostData = $.extend({}, GetPostData(), { - page: jgrid.jqGrid('getGridParam', 'page'), - rows: jgrid.jqGrid('getGridParam', 'rowNum'), - sidx: jgrid.jqGrid('getGridParam', 'sortname'), - sord: jgrid.jqGrid('getGridParam', 'sortorder') - }); - $.ajax({ - url: queryurl, - type: 'POST', - dataType: 'json', - traditional: true, - data: exportPostData - }).done(function (result) { - main.exportVisibleColumnsToExcel(jgrid, fileName, null, result && result.rows ? result.rows : []); - }).fail(function () { - main.message && main.message('导出失败,无法获取日终持仓数据'); - }); -} - function getVisibleEodSwapBusinessColumnNames(jgrid) { var colModel = jgrid.jqGrid('getGridParam', 'colModel') || []; return colModel.filter(function (col) { @@ -824,15 +794,13 @@ function exportEodSwapRows(jgrid, fileName, groupConfig, exportColumnNames) { } //---------------------------Formatter--------------------------------- function PriceFormat(cellValue, options, rowObject) { - return otcformat.trading.umprice(cellValue); -} - -function ExportPriceNineDecimalFormat(cellValue) { - if (cellValue === null || cellValue === undefined || cellValue === '') { - return ''; - } - var price = Number(cellValue); - return isFinite(price) ? price.toFixed(9) : cellValue; + // 净价、全价可在配置中独立维护;只有期初净价读取 netPrice,其余价格列保持全价口径。 + var columnName = options && options.colModel && (options.colModel.name || options.colModel.index); + var precisionField = columnName === 'eodPosition.PosiNetPrice' ? 'netPrice' : 'grossPrice'; + return swapPricePrecision.format( + cellValue, + rowObject && rowObject.eodPosition && rowObject.eodPosition.UnderlyingInstrumentType, + precisionField); } function RealizedPnlFormat(cellValue, options, rowObject) { @@ -851,14 +819,21 @@ function NullableStockEqvNotionalFormat(cellValue, options, rowObject) { function PosiStatusFormat(cellValue, options, rowObject) { return cellValue == 1 ? "已平" : "正常"; } -function PositionTypeFormat(cellValue, options, rowObject) { - if (cellValue == 1) { - return "B"; +function TradeDirectionFormat(cellValue, options, rowObject) { + var eodPosition = rowObject && rowObject.eodPosition; + if (!eodPosition) { + return ""; } - if (cellValue == 2) { - return "S"; + + var posiDirection = Number(eodPosition.PosiDirection); + var positionType = Number(eodPosition.PositionType); + if (posiDirection === 2) { + positionType = positionType === 1 ? 2 : positionType === 2 ? 1 : 0; + } else if (posiDirection !== 1) { + return ""; } - return ""; + + return positionType === 1 ? "多头" : positionType === 2 ? "空头" : ""; } function DrictionFormat(cellValue, options, rowObject) { if (cellValue == 1) { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js index c8c32767..e9f8f6b7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/eventlist.js @@ -176,31 +176,36 @@ var getColModelGrid = function () { label: '成交全价', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'TradingAmountFeeAvg', label: '成交全价(含费)', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'TradingAmountNetAvg', label: '成交净价', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'InitYtm', label: '成交收益率', width: 90, align: 'center', - formatter: EventNineDecimalFormat + formatter: function (cellValue, options, rowObject) { + if (cellValue == null) { + return ""; + } + return otcformat.trading.premiumRateP(cellValue); + } }, { name: 'TradingAmountNetFeeAvg', label: '成交净价(含费)', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'Quantity', label: '成交数量/张数', @@ -212,7 +217,7 @@ var getColModelGrid = function () { label: '成交金额(元)', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'ContractSize', label: '乘数', @@ -225,31 +230,31 @@ var getColModelGrid = function () { label: '交易费用佣金', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'TradingFeePending', label: '待结算交易费用佣金', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'DividendPending', label: '待结算分红收益', width: 90, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'MarkClosePnl', label: '浮动端平仓盈亏·浮动', width: 160, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice }, { name: 'DividendIn', label: '浮动端平仓盈亏·分红', width: 160, align: 'center', - formatter: EventTwoDecimalFormat + formatter: otcformat.trading.umprice } ]; return col; @@ -257,23 +262,6 @@ var getColModelGrid = function () { var colModelGrid = getColModelGrid(); -function EventTwoDecimalFormat(cellValue) { - if (cellValue === null || cellValue === undefined || cellValue === '') { - return ''; - } - var price = Number(cellValue); - return isFinite(price) ? price.toFixed(2) : cellValue; -} - -function EventNineDecimalFormat(cellValue) { - if (cellValue === null || cellValue === undefined || cellValue === '') { - return ''; - } - var price = Number(cellValue); - return isFinite(price) ? price.toFixed(9) : cellValue; -} - - function gridComplete() { $('.ui-jqgrid-bdiv', '#gbox_listGrid').floatingScroll(); } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index a0057f28..2159db48 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -73,14 +73,18 @@ const vue = new Vue({ $(this.$refs.incomeValueDatePicker.$el).val(MaxIncomeValueDate); return false; }, - // 守卫: 价格缩放因子(债券 multiplier=100 时界面为百分比态, 计算用相对价需 ÷100) - // 计算已外置到 swapCalc.getPriceScale; 改动需同步 swapCalc.test.js - getPriceScale() { - return SwapCalc.getPriceScale(this.multiplier); + getDeliveryPriceInputFormat() { + return swapPricePrecision.getInputFormat( + this.floatPosition && this.floatPosition.UnderlyingInstrumentType, + 'grossPrice', + inputFormatSwapDeliveryPrice); }, getStorageDeliveryPrice() { - const precision = inputFormatSwapDeliveryPrice.precision + (this.multiplier === 100 ? 2 : 0); - return SwapCalc.roundHalfAwayFromZero(Number(this.floatPosition.TradingAmountAvg) * this.getPriceScale(), precision); + return swapPricePrecision.roundForSubmit( + swapPricePrecision.shiftDecimal(this.floatPosition.TradingAmountAvg, this.multiplier === 100 ? -2 : 0), + this.floatPosition && this.floatPosition.UnderlyingInstrumentType, + 'grossPrice', + this.multiplier === 100 ? 2 : 0); }, initDeal() { var positions = model.FlowEvents.filter((item) => { @@ -110,8 +114,8 @@ const vue = new Vue({ return tradeHelper.IsBond(instType); }, priceFormat(price) { - price = price * this.multiplier; - var pricef = otcformat.trading.umprice(price); + price = swapPricePrecision.shiftDecimal(price, this.multiplier === 100 ? 2 : 0); + var pricef = swapPricePrecision.format(price, this.floatPosition && this.floatPosition.UnderlyingInstrumentType, 'grossPrice'); return pricef; }, dataFormat() { @@ -165,8 +169,10 @@ const vue = new Vue({ main.post("/underlying_manager/GetUnderlyingPriceByCode", { code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate }) .done(function (res) { - res.obj = res.obj * thisObj.multiplier; - thisObj.floatPosition.TradingAmountAvg = _.round(Number(res.obj), 9); + thisObj.floatPosition.TradingAmountAvg = swapPricePrecision.roundForSubmit( + swapPricePrecision.shiftDecimal(res.obj, thisObj.multiplier === 100 ? 2 : 0), + thisObj.floatPosition.UnderlyingInstrumentType, + 'grossPrice'); thisObj.calcFloatClosePnl(); }); }, @@ -382,6 +388,7 @@ const vue = new Vue({ components: { 'vue-datepicker': FastVue.vueDatePicker(), 'vue-number-input': FastVue.vueNumberInput(), + 'vue-swap-price-input': swapPricePrecision.createVueInputComponent(), } }); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 58e7cfbe..dfcea9a7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -201,19 +201,20 @@ } /** - * 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。 - * - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的 + * 债券净价/全价/收益率三字段互算:以 driver(CP/DP/YD) 为"源",回写时跳过源字段(保留用户手输值), + * 其余两字段用计算器结果覆盖。 + * - driver: 'CP'净价 / 'DP'全价 / 'YD'收益率,即本次回车的输入依据 * - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj * - state: 持有三个字段的对象(直接原地写回) - * 设计(回应"计算结果不认可时如何优雅手动覆盖"): - * 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。 - * 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。 + * 设计(对应交互约定1+2): + * 回车某字段即以其为源调计算器,另两个字段被反算覆盖;源字段保持用户手输值不被回写。 + * 故用户回车净价→全价/收益率被重算;再次回车全价→净价/收益率被重算(最后回车者恒为源)。 * 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。 */ - function applyBondCalcResult(state, calc, manualSet) { + function applyBondCalcResult(state, calc, driver) { var EPS = 1e-4; function write(field, type, value) { - if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写 + if (driver === type) return; // 源字段:保留用户手输值,不回写 if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖 var cur = state[field]; if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动 @@ -241,15 +242,64 @@ return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数 } /** - * 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。 - * 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType) - * 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复)。 - * 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual), - * 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。 + * 清空债券三字段互算的【源/AUTO/REV】标志(纯函数,jest 可测)。 + * 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的状态污染新债券(D1 修复)。 + * 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象,重赋值能正常触发响应式更新。 */ function clearBondCalcFlags(state) { - state.bondManual = { CP: false, DP: false, YD: false }; state.bondDriverType = null; + state.bondAuto = { CP: false, DP: false, YD: false }; + state.bondRev = { CP: false, DP: false, YD: false }; + return state; + } + + /** + * 交互约定2(纯函数,jest 可测):计算器调用【成功】后的标识落地。 + * - 以 driver(CP/DP/YD) 为"源",标记 bondDriverType=driver; + * - 其余两字段标记 bondAuto=true("AUTO"=计算器算出的); + * - 清除 bondRev(人工输入标记)。 + * 不写任何价格数值——数值由 applyBondCalcResult 回写。 + */ + function applyBondCalcSuccess(state, driver) { + state.bondDriverType = driver; + state.bondAuto = { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' }; + state.bondRev = { CP: false, DP: false, YD: false }; + return state; + } + + /** + * 交互约定2(纯函数,jest 可测):计算器调用【失败】后的标识与数值落地。 + * - 保留用户刚回车的"源"字段值(driver),让用户可就地修改; + * - 另两个字段清空为空白("另外两个数值都更新为空白"); + * - 三个数值旁边的标识全部清空("三个数值旁边都清空标识")。 + */ + function applyBondCalcFailure(state, driver) { + var fields = { CP: 'PosiNetNoFeePrice', DP: 'PosiGrossPrice', YD: 'InitYtm' }; + Object.keys(fields).forEach(function (t) { + if (t !== driver) state[fields[t]] = null; // 非源字段清空 + }); + state.bondDriverType = null; + state.bondAuto = { CP: false, DP: false, YD: false }; + state.bondRev = { CP: false, DP: false, YD: false }; + return state; + } + + /** + * 交互约定3(纯函数,jest 可测):编辑某字段但未回车(失焦/输入)时的标识落地。 + * - 清除 源 与 AUTO 标识(不再代表任何已算结果); + * - 本字段标 bondRev[type]=true("REV"=人工输入),不联动另外两字段; + * - ★ 累计 REV(不清空其它已手工编辑字段的 REV):连续手动编辑多字段(不回车)时, + * 每个被改过的字段都保留 REV,使 UI 正确显示"全部为人工输入",且不会被后续计算器覆盖 + * (见文档步骤3-b)。此前的实现每次重置全部 REV 再只标当前字段,导致"只有最后编辑的字段留 REV", + * 中间手工编辑的字段 REV 被静默抹掉(步骤3-b 不满足需求)。 + */ + function applyBondManualEdit(state, type) { + state.bondDriverType = null; + state.bondAuto = { CP: false, DP: false, YD: false }; + if (!state.bondRev || typeof state.bondRev !== 'object') { + state.bondRev = { CP: false, DP: false, YD: false }; + } + if (type) state.bondRev[type] = true; // 累加:仅置当前字段,保留其它已手工编辑字段的 REV return state; } @@ -296,16 +346,16 @@ } /** - * D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时, - * 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值。 - * - 不设置 bondDriverType:重开时没有任何字段作为"计算源",calcBondForItem 会早返回(要求 driver 非空), - * 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导。 - * - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true, - * 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。 - * 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。 + * 加载路径(重开/刷新已保存债券单)调用(纯函数,jest 可测): + * 把三字段互算的状态标识全部清空,使已保存的净价/全价/收益率纯展示、无任何 源/AUTO/REV 标记。 + * - 不锁定、不置源:交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格; + * 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。 + * 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券无副作用。 */ - function markExistingBondManual(state) { - state.bondManual = { CP: true, DP: true, YD: true }; + function clearBondCalcMarksOnLoad(state) { + state.bondDriverType = null; + state.bondAuto = { CP: false, DP: false, YD: false }; + state.bondRev = { CP: false, DP: false, YD: false }; return state; } @@ -336,13 +386,49 @@ "若暂不需要计算,可手动填写净价/全价/收益率三项数值"; } + /** + * 判断按键是否会【改变】债券三字段的数值(纯函数,jest 可测)。 + * 用于 onBondPriceKeydown:仅"会改变数值"的按键才清 源/AUTO 标识并标本字段 REV; + * 其余(回车/修饰键/导航键/功能键/纯 Shift)一律不改标识,避免"没改值却出现 REV"。 + * 正向白名单(最稳,不会漏掉未来新增的导航键): + * - 数字键:主键盘 48-57 / 小键盘 96-105 + * - 小数点:主键盘 190 / 小键盘 110 + * - 减号: 主键盘 189 / 小键盘 109 + * - Backspace(8) / Delete(46) + * 明确排除:回车(13/108,由 v-on:enter 单独处理)、Ctrl/Alt/Meta 组合键、 + * Tab(9)/Home(36)/End(35)/←(37)/→(39)/↑(38)/↓(40)/Insert(45)/PageUp(33)/PageDown(34)、 + * F1-F12(112-123)、纯 Shift(16)。这些键不改数值,不应清标识/标 REV。 + */ + function isBondPriceValueKey(event) { + if (!event) return false; + if (event.ctrlKey || event.altKey || event.metaKey) return false; // 组合键(如 Ctrl+C/V)不改数值 + var kc = event.which || event.keyCode; + if (kc === 13 || kc === 108) return false; // 回车由 enter 事件处理 + // 纯 Shift 单独按下不改数值(Shift+数字在数值框不产生数字,亦不改数值) + if (kc === 16) return false; + // 导航/功能键不改数值 + if (kc >= 33 && kc <= 40) return false; // PageUp/PageDown/End/Home/←/↑/→/↓ + if (kc === 45) return false; // Insert + if (kc >= 112 && kc <= 123) return false; // F1-F12 + if (kc === 9) return false; // Tab + // 以下为会改变数值的键 + var valueKeys = [8, 46, 48,49,50,51,52,53,54,55,56,57, + 96,97,98,99,100,101,102,103,104,105, + 109,110,189,190]; + return valueKeys.indexOf(kc) !== -1; + } + return { applyBondCalcResult: applyBondCalcResult, + isBondPriceValueKey: isBondPriceValueKey, getBondCalcErrorMessage: getBondCalcErrorMessage, bondPriceToCalc: bondPriceToCalc, bondCalcPriceToStorage: bondCalcPriceToStorage, clearBondCalcFlags: clearBondCalcFlags, - markExistingBondManual: markExistingBondManual, + applyBondCalcSuccess: applyBondCalcSuccess, + applyBondCalcFailure: applyBondCalcFailure, + applyBondManualEdit: applyBondManualEdit, + clearBondCalcMarksOnLoad: clearBondCalcMarksOnLoad, shouldShowBondErr: shouldShowBondErr, getBondStartDateMissingMsg: getBondStartDateMissingMsg, roundHalfAwayFromZero: roundHalfAwayFromZero, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js new file mode 100644 index 00000000..be9e3ed6 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js @@ -0,0 +1,265 @@ +var swapPricePrecision = (function (global) { + const defaults = Object.freeze({ + Stock: { integerDigits: 7, precision: 2 }, + StockIndex: { integerDigits: 7, precision: 2 }, + StockIF: { integerDigits: 7, precision: 4 }, + CommodityFutures: { integerDigits: 7, precision: 4 }, + CommoditySpot: { integerDigits: 7, precision: 4 }, + NewOtcStock: { integerDigits: 7, precision: 4 }, + HKStock: { integerDigits: 7, precision: 4 }, + HKStockIndex: { integerDigits: 7, precision: 4 }, + Fund: { integerDigits: 7, precision: 4 }, + Bond: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + TBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + CreditBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + OtherBonds: { + grossPrice: { integerDigits: 6, precision: 9 }, + netPrice: { integerDigits: 6, precision: 9 }, + yield: { integerDigits: 2, precision: 4 } + }, + TBFutures: { integerDigits: 8, precision: 4 }, + OtherFutures: { integerDigits: 8, precision: 4 }, + GoldSpot: { integerDigits: 8, precision: 4 }, + OtherSpot: { integerDigits: 8, precision: 4 }, + AbroadFutures: { integerDigits: 8, precision: 4 }, + AbroadSpot: { integerDigits: 8, precision: 4 }, + AbroadStock: { integerDigits: 8, precision: 2 }, + AbroadStockIndex: { integerDigits: 8, precision: 4 }, + ExRate: { integerDigits: 2, precision: 8 }, + Shibor: { integerDigits: 2, precision: 4 }, + FixingRepoRate: { integerDigits: 2, precision: 4 } + // TODO: Add InterestYield, BondIndex and GoldFutures after their enum values are confirmed. + }); + + function normalizeDecimal(value) { + if (value === null || value === undefined || value === '') return ''; + let text = String(value).trim(); + if (/[eE]/.test(text)) { + const number = Number(text); + if (!Number.isFinite(number)) return null; + text = number.toFixed(20).replace(/0+$/, '').replace(/\.$/, ''); + } + if (!/^[+-]?(?:\d+|\d*\.\d+)$/.test(text)) return null; + const negative = text.charAt(0) === '-'; + text = text.replace(/^[+-]/, ''); + const parts = text.split('.'); + const integerPart = parts[0].replace(/^0+(?=\d)/, '') || '0'; + const decimalPart = parts.length > 1 ? parts[1] : ''; + const result = integerPart + (decimalPart ? '.' + decimalPart : ''); + return negative && !/^0(?:\.0*)?$/.test(result) ? '-' + result : result; + } + + function shiftDecimal(value, places) { + let normalized = normalizeDecimal(value); + if (!normalized || !Number.isInteger(places) || places === 0) return normalized; + const negative = normalized.charAt(0) === '-'; + const parts = (negative ? normalized.substring(1) : normalized).split('.'); + const integerPart = parts[0]; + const decimalPart = parts.length > 1 ? parts[1] : ''; + const digits = integerPart + decimalPart; + const decimalIndex = integerPart.length + places; + let text; + if (decimalIndex <= 0) text = '0.' + '0'.repeat(-decimalIndex) + digits; + else if (decimalIndex >= digits.length) text = digits + '0'.repeat(decimalIndex - digits.length); + else text = digits.substring(0, decimalIndex) + '.' + digits.substring(decimalIndex); + return normalizeDecimal((negative ? '-' : '') + text); + } + + function incrementDigits(value) { + let carry = 1; + let result = ''; + for (let index = value.length - 1; index >= 0; index--) { + const digit = value.charCodeAt(index) - 48 + carry; + if (digit === 10) { + result = '0' + result; + carry = 1; + } else { + result = String(digit) + result; + carry = 0; + } + } + return carry ? '1' + result : result; + } + + function roundDecimal(value, precision) { + const normalized = normalizeDecimal(value); + if (normalized === null || !Number.isInteger(precision) || precision < 0) return value; + const negative = normalized.charAt(0) === '-'; + const parts = (negative ? normalized.substring(1) : normalized).split('.'); + let integerPart = parts[0]; + const decimalPart = parts.length > 1 ? parts[1] : ''; + if (decimalPart.length <= precision) return normalized; + + let digits = integerPart + decimalPart.substring(0, precision); + if (decimalPart.charAt(precision) >= '5') digits = incrementDigits(digits); + if (digits.length <= precision) digits = digits.padStart(precision + 1, '0'); + integerPart = precision === 0 ? digits : digits.substring(0, digits.length - precision); + const roundedDecimal = precision === 0 ? '' : digits.substring(digits.length - precision); + return normalizeDecimal((negative ? '-' : '') + integerPart + (roundedDecimal ? '.' + roundedDecimal : '')); + } + + function normalizeRule(rule) { + if (!rule || typeof rule !== 'object') return null; + const integerDigits = Number(rule.integerDigits); + const precision = Number(rule.precision); + if (!Number.isInteger(integerDigits) || integerDigits < 1 || integerDigits > 18 + || !Number.isInteger(precision) || precision < 0 || precision > 13) return null; + return { integerDigits: integerDigits, precision: precision }; + } + + function findRule(source, instrumentType, field) { + const typeRule = source && source[instrumentType]; + return typeRule ? normalizeRule(typeRule[field] || typeRule) : null; + } + + function getRule(instrumentType, field) { + const fallback = findRule(defaults, instrumentType, field); + if (!fallback) return null; + return findRule(global.main && global.main.swapPricePrecision, instrumentType, field) || fallback; + } + + function format(value, instrumentType, field) { + const rule = getRule(instrumentType, field); + if (value === null || value === undefined || value === '') return ''; + if (!rule) return global.otcformat.trading.umprice(value); + const rounded = roundDecimal(value, rule.precision); + return rounded === null ? '' : rounded.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, ''); + } + + function normalizeInput(value, format, shouldRound) { + const options = format || {}; + const maxIntegerDigits = Number(options.integerDigits) || 0; + const precision = Number(options.precision) || 0; + const source = String(value === null || value === undefined ? '' : value).trim().replaceAll(',', '').replaceAll('。', '.'); + let negative = false; + let hasDot = false; + let integerPart = ''; + let decimalPart = ''; + for (let index = 0; index < source.length; index++) { + const ch = source.charAt(index); + if (ch >= '0' && ch <= '9') { + if (hasDot) { + if (shouldRound || decimalPart.length < precision) decimalPart += ch; + } else if (!maxIntegerDigits || integerPart.length < maxIntegerDigits) { + integerPart += ch; + } + } else if (ch === '.' && !hasDot && precision > 0) { + hasDot = true; + } else if (ch === '-' && index === 0 && options.negative) { + negative = true; + } + } + if (!integerPart && !decimalPart) return negative ? '-' : ''; + const text = (negative ? '-' : '') + (integerPart || '0') + (hasDot ? '.' + decimalPart : ''); + if (!shouldRound || text.endsWith('.')) return text; + return roundDecimal(text, precision); + } + + function createVueInputComponent() { + return { + props: { + value: { type: [Number, String], default: '' }, + format: { type: Object, default: function () { return {}; } }, + disabled: { type: Boolean } + }, + data: function () { + return { text: '', enterPressed: false, formatSnapshot: '' }; + }, + mounted: function () { + this.text = this.toDisplay(this.value); + this.formatSnapshot = JSON.stringify(this.format || {}); + }, + methods: { + toDisplay: function (value) { + if (value === null || value === undefined || value === '') return ''; + const displayValue = this.format && this.format.percent ? shiftDecimal(value, 2) : String(value); + return normalizeInput(displayValue, this.format, true); + }, + toModel: function (value) { + if (!value || value === '-') return ''; + return this.format && this.format.percent ? shiftDecimal(value, -2) : value; + }, + updateValue: function (value, shouldRound, shouldCommit) { + this.text = normalizeInput(value, this.format, shouldRound); + if (this.text.endsWith('.') && !shouldRound) return; + if (shouldCommit) this.$emit('input', this.toModel(this.text)); + }, + onInput: function (event) { + this.updateValue(event.target.value, false, false); + // When the normalized value is unchanged, Vue skips the DOM patch. + // Write it directly so excess digits do not remain in the native input. + event.target.value = this.text; + }, + onPaste: function (event) { + const clipboard = event.clipboardData || global.clipboardData; + if (!clipboard) return; + event.preventDefault(); + this.updateValue(clipboard.getData('text'), true, false); + event.target.value = this.text; + }, + onKeydown: function (event) { + this.enterPressed = false; + this.$emit('keydown', event); + const keyCode = event.which || event.keyCode; + if (event.key !== 'Enter' && keyCode !== 13 && keyCode !== 108) return; + this.enterPressed = true; + event.target.blur(); + }, + onChange: function (event) { + if (this.text.endsWith('.')) this.text = this.text.substring(0, this.text.length - 1); + this.updateValue(this.text, true, false); + const value = this.toModel(this.text); + this.$emit('input', value); + if (this.enterPressed) this.$emit('enter', value); + this.enterPressed = false; + event.target.value = this.text; + } + }, + watch: { + value: function (value) { + const display = this.toDisplay(value); + if (display !== this.text) this.text = display; + }, + format: { + deep: true, + handler: function () { + const snapshot = JSON.stringify(this.format || {}); + if (snapshot === this.formatSnapshot) return; + this.formatSnapshot = snapshot; + this.text = this.toDisplay(this.value); + } + } + }, + template: '' + }; + } + + return Object.freeze({ + getRule: getRule, + getInputFormat: function (instrumentType, field, options) { + const rule = getRule(instrumentType, field); + return rule ? Object.assign({}, options, rule) : Object.assign({}, options); + }, + format: format, + roundForSubmit: function (value, instrumentType, field, storagePrecisionOffset) { + const rule = getRule(instrumentType, field); + if (value === null || value === undefined || value === '' || !rule) return value; + const offset = Number.isInteger(storagePrecisionOffset) ? storagePrecisionOffset : 0; + return roundDecimal(value, rule.precision + offset); + }, + shiftDecimal: shiftDecimal, + createVueInputComponent: createVueInputComponent + }); +}(window)); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index c3eecede..7f0e627c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -1,5 +1,8 @@ //otcformat禁止千分位分组 window.otcformat.options.disableGrouping = true; +// 模块加载横幅(排查用):仅在 ?otcdebug=1 或 localStorage.otcdebug=1 开启时打印,含 bundle 版本+git sha。 +// otcDebug 由 main.js 提供(在 bundle.js 内),本文件是独立
收支方向收支方向多空方向 标的代码浮动端平仓盈亏
@((SwapDirectionEnum)closeFloat.PayDirection)@((SwapDirectionEnum)closeFloat.PayDirection)@(closeFloat.PositionType == (int)PositionTypeFlag.Long ? "多头" : "空头") @closeFloat.UnderlyingCode@((closeFloat.PosiGrossPrice * multiplier).OtcFormat(OtcFormatFlag.umprice))@(((closeFloat.TradingAmountNetAvg ?? 0) * multiplier).OtcFormat(OtcFormatFlag.umprice))@((closeFloat.TradingAmountAvg * multiplier).OtcFormat(OtcFormatFlag.umprice))@((closeFloat.TradingAmountAvg * multiplier).OtcFormat(OtcFormatFlag.umprice)) @((closeFloat.PositionQty??0).OtcFormat(OtcFormatFlag.StockEqvNotional)) @(closeFloat.TradingFee.OtcFormat(OtcFormatFlag.StockEqvNotional)) @(closeFloat.DividendIn.OtcFormat(OtcFormatFlag.StockEqvNotional))