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/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/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/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/YLErpDAL/Modules/AppModule/AppUpgrader.cs b/YLErpDAL/Modules/AppModule/AppUpgrader.cs index ead11cb0..945744b1 100644 --- a/YLErpDAL/Modules/AppModule/AppUpgrader.cs +++ b/YLErpDAL/Modules/AppModule/AppUpgrader.cs @@ -465,9 +465,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 b9cee1fb..20237217 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -307,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; @@ -324,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); } /// /// 校验上日是否收盘 diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index ffd85f89..d83a4d06 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; @@ -261,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; } @@ -571,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); } @@ -604,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); @@ -700,7 +719,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)) { 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/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/otcformat.js b/YLErpWeb/App_Data/Config/otcformat.js index d4c79353..92b1f226 100644 --- a/YLErpWeb/App_Data/Config/otcformat.js +++ b/YLErpWeb/App_Data/Config/otcformat.js @@ -1,125 +1,2 @@ var main = main || {}; -main.formatOptions={ - "trading": { - "umprice": { - "precision": 9, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 9, - "maxDecimals": 0 - }, - "umpriceP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "umpricePR": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "tradeSinglePrice": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "premiumRateP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "premiumRate": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "tradePrice": { - "precision": 2, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "StockEqvNotional": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "notional": { - "precision": 2, - "grouping": true, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "notionalP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "volatility": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - }, - "volatilityP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "greek": { - "precision": 2, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 2, - "maxDecimals": 0 - }, - "marginRateP": { - "percent": true, - "precision": 2, - "grouping": false, - "rounded": true, - "minDecimals": 2, - "maxDecimals": 0 - }, - "marginRate": { - "precision": 4, - "grouping": false, - "rounded": true, - "percent": false, - "minDecimals": 4, - "maxDecimals": 2 - } - } -}; \ No newline at end of file +main.formatOptions = { "trading": { "umprice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "umpriceP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "umpricePR": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradeSinglePrice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "premiumRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "premiumRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradePrice": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "StockEqvNotional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notionalP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "volatility": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "volatilityP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "greek": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "marginRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "marginRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 } } }; \ No newline at end of file diff --git a/YLErpWeb/Common/HtmlUtil.cs b/YLErpWeb/Common/HtmlUtil.cs index cc3e6247..1292f24e 100644 --- a/YLErpWeb/Common/HtmlUtil.cs +++ b/YLErpWeb/Common/HtmlUtil.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Html; -using System.Collections; +using System.Reflection; using YLErp.Events; namespace YLErp @@ -17,6 +17,12 @@ 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() @@ -27,6 +33,13 @@ namespace YLErp BinFileVersion = files.Any() ? files.Max(n => n.LastWriteTime) : DateTime.MinValue; JsVersion = BinFileVersion.ToString("yyMMddHHmmss"); + //从 AssemblyInformationalVersion 读取 git sha(SDK 编译时已嵌入,零额外依赖) + var infoVer = typeof(HtmlUtil).Assembly + .GetCustomAttribute()?.InformationalVersion; + //格式 "1.0.0+",取 + 之后部分;无则回退到完整字符串 + GitCommit = string.IsNullOrEmpty(infoVer) ? "unknown" + : (infoVer.Contains('+') ? infoVer.Substring(infoVer.LastIndexOf('+') + 1) : infoVer); + EventBus.Subscribe(t => { _dataCacheUpdateTime = DateTimeOffset.Now.ToUnixTimeSeconds(); 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 @@