diff --git a/Framework/YLErp.Core/DBModels/Approvalprocess.cs b/Framework/YLErp.Core/DBModels/Approvalprocess.cs index ee1339a2..5af5e653 100644 --- a/Framework/YLErp.Core/DBModels/Approvalprocess.cs +++ b/Framework/YLErp.Core/DBModels/Approvalprocess.cs @@ -70,5 +70,9 @@ namespace YLErp.DBModels /// [DisplayName("节点触发条件")] public string triggerCondition { get; set; } + + /// 是否在进入该节点时关联国联民生 OA 移动审批。 + public bool isOaApproval { get; set; } + } } diff --git a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs index 95d6fc27..5f607554 100644 --- a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs @@ -20,6 +20,7 @@ namespace YLErp.DBModels 确认交易=9, 审批通过=10, 审批拒绝=11, - 删除=12 + 删除=12, + 公司行为=13 } } diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index 27d51c07..a57996d5 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -82,6 +82,37 @@ namespace YLErp.DBModels [NotMapped] public UnwindData unwindData { get; set; } } + + /// + /// 公司行为事件快照。登记日先写入待生效快照,真实除权日补齐调整后数据; + /// 已应用快照只允许追加回退事件,不覆盖原记录。 + /// ExDividendDate 是登记日,EffectiveDate 是 Q/P 真实切换日;GiveShareAmount + /// 表示每 10 份增减数量,Split 表示独立拆/合股倍数(null 按 1)。Before/After + /// 分别保存调整前后名义本金、价格、数量和待实现分红,CashFlowChange 保存现金变化。 + /// + public class CorporateActionEventData + { + public int ExDividendInfoId { get; set; } + public long PositionId { get; set; } + public string UnderlyingCode { get; set; } + public DateTime? ExDividendDate { get; set; } + public DateTime? EffectiveDate { get; set; } + public decimal GiveCashAmount { get; set; } + public decimal GiveShareAmount { get; set; } + public decimal? Split { get; set; } + public decimal RationedSharesAmount { get; set; } + public decimal RationedSharesPrice { get; set; } + public decimal BeforeNotional { get; set; } + public decimal BeforePrice { get; set; } + public decimal BeforeQuantity { get; set; } + public decimal AfterNotional { get; set; } + public decimal AfterPrice { get; set; } + public decimal AfterQuantity { get; set; } + public decimal BeforePendingDividend { get; set; } + public decimal AfterPendingDividend { get; set; } + public decimal CashFlowChange { get; set; } + public bool Applied { get; set; } + } /// /// 展期信息 /// diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index b635b672..7b61672f 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -400,5 +400,18 @@ namespace YLErp.DBModels [NotMapped] public decimal? InitYtm { get; set; } + /// + /// 期末标的结算收益率(EQD-6953 平仓)。普通债券类收益互换平仓时由债券计算器按 + /// 期末标的交割全价反算(估值日=平仓日 ValueDate),允许手工覆盖。 + /// 命名遵循《互换价格字段命名规范决策文档》时点维度:平仓/了结用 Exit(勿用 End/Close/Final)。 + /// [NotMapped]:不落 swap_flow_event 表列;仅随 UnwindData 序列化进 swap_event.EventData JSON, + /// 由平仓待复核回显(GetSwapEvent)与结算确认书 Excel(TradeSettleBillGenerator) 消费。 + /// ⚠️ 存储口径为【展示态百分数】(如 6.3721 表示 6.3721%),与同页期末交割全价(展示态)一致, + /// 区别于录入页 trade.InitYtm 的存储态小数(0.063721)——两者载体不同、互不干扰,勿"顺手统一"。 + /// 精度:确认书导出固定 4 位小数不去零(ToString("0.0000"));本字段保留 4 位(四舍五入)。 + /// + [NotMapped] + public decimal? ExitYtm { get; set; } + } } diff --git a/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs new file mode 100644 index 00000000..66be6224 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + /// + /// 衍生品交易审批节点对应的国联民生 OA 流程记录。 + /// + [Table("trade_approval_oa_result")] + public class trade_approval_oa_result : DBModelBaseV6 + { + public int trade_id { get; set; } + public int approval_process_id { get; set; } + public string process_type { get; set; } + public int? applicant_id { get; set; } + public string applicant_login_name { get; set; } + public string oa_fileid { get; set; } + public string status { get; set; } + public string oa_msg { get; set; } + public string request_payload { get; set; } + public string last_response { get; set; } + public DateTime? last_query_time { get; set; } + public bool is_valid { get; set; } + } +} diff --git a/Framework/YLErp.Core/DBModels/underlying_manager.cs b/Framework/YLErp.Core/DBModels/underlying_manager.cs index 2808b5f5..b1187b6e 100644 --- a/Framework/YLErp.Core/DBModels/underlying_manager.cs +++ b/Framework/YLErp.Core/DBModels/underlying_manager.cs @@ -137,6 +137,20 @@ namespace YLErp.DBModels [DisplayName("标的名称")] public string UnderlyingName { set; get; } + /// + /// 基金及基金专户的基金管理人名称 + /// + [Column("investadvisorname")] + [DisplayName("基金管理人")] + public string InvestAdvisorName { get; set; } + + /// + /// 基金及基金专户所属的 ETF 子类,保存“ETF 子类”字典项名称 + /// + [Column("etf_sub_type")] + [DisplayName("ETF 子类")] + public string EtfSubType { get; set; } + /// /// 标的英文名 diff --git a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs index b796debe..55ddcf9e 100644 --- a/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs +++ b/Framework/YLErp.Core/Models/SwapEndConfirmModel.cs @@ -45,5 +45,11 @@ namespace YLErp.Models public string DividendIn { get; set; } public string Quantity { get; set; } + + /// + /// 期末标的结算收益率(EQD-6953)。普通债券类收益互换平仓收益率,展示态百分数, + /// 固定 4 位小数不去零("0.0000");非债券/历史无值时为空串。 + /// + public string ExitYtm { get; set; } } } diff --git a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml index 97770296..b56ab947 100644 --- a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml +++ b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml @@ -42,4 +42,17 @@ + + + + + + + + + + + + + diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx index 573b1546..030e6c1c 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx index f5034526..c6da85b2 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看空】-【债券ETF】-清洁版.docx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx index 2cf6a553..0415cc61 100644 Binary files a/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx and b/Plugins/YLErp.Plugins.GuoLian/App_Docs/settlement_template/nodma_01.xlsx differ diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index a4047271..e89a5ed7 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -508,7 +508,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["参考标的名义份额"] = swapPosition != null ? ((double)swapPosition.PosiQuantity).ToString("0.##") : "0"; - dic["参考标的基金管理人"] = ""; + + dic["参考标的基金管理人"] = underlying?.InvestAdvisorName ?? ""; var contractTypeId = (Context.GetContractTypes().FirstOrDefault(O => O.ContactType == "交易确认书接收")?.id) ?? 0; // 乙方联系人信息 var clientDuties = Context.GetClientDuties().Where(O => O.ContactTypeIdsInt.Contains(contractTypeId)).ToList(); diff --git a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs index 7523323b..adbb66b5 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeSettleBillGenerator.cs @@ -48,7 +48,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator var confirmNo = Context.Gettrade_contract_r(tradeId, ContractTypeEnum.Trade); if (string.IsNullOrEmpty(confirmNo)) { - throw new ServiceException($"{trade.TradeNumber}未生成交易确认书"); + // 定位要点:带上事件id便于与 SwapSettlementBillGenerateService 的"生成范围扩张日志"对齐—— + // 报错交易常是扩张拉入的同客户同日平仓,并非用户勾选的那笔。 + throw new ServiceException($"{trade.TradeNumber}未生成交易确认书(平仓事件id={flowEventGroup.id}, tradeId={tradeId}, 客户={client.Name}, 平仓日={flowEventGroup.UnwindDate?.ToString("yyyy-MM-dd")});请先为该笔交易生成交易确认书后重试"); } row.TradeNumber = confirmNo; row.ClientName = client.Name; @@ -67,6 +69,9 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator decimal interestRate = unwindFlowEvents.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).Sum(s => s.InterestRate); row.InterestRate = interestRate.ToString("0.00%"); var PosiNotionalValue = flowEventGroup.Quantity * flowEventGroup.ContractSize * posi.PosiGrossPrice; + // EQD-6953 期末标的结算收益率:平仓簿记时随 UnwindData 存进 swap_event.EventData, + // 此处从浮动腿(PositionType>0)回读。存储态=展示态百分数(6.3721),导出固定 4 位不去零。 + decimal? exitYtm = null; if (flowEventGroup.EventId.HasValue) { var swapEvent = Context.GetEvent(flowEventGroup.EventId.Value); @@ -74,8 +79,12 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator { swapEvent.unwindData = JsonHelper.Deserialize(swapEvent.EventData); PosiNotionalValue = swapEvent.unwindData.CloseNotionalValue; + exitYtm = swapEvent.unwindData.FlowEvents? + .FirstOrDefault(f => f.PositionType > 0)? + .ExitYtm; } } + row.ExitYtm = exitYtm?.ToString("0.0000") ?? string.Empty; row.Quantity = flowEventGroup.Quantity.ToString("0.00"); row.PosiNotionalValue = PosiNotionalValue.ToString("0.00"); diff --git a/UnitTestProject/Modules/BondCalcHeplerTest.cs b/UnitTestProject/Modules/BondCalcHeplerTest.cs new file mode 100644 index 00000000..084f5236 --- /dev/null +++ b/UnitTestProject/Modules/BondCalcHeplerTest.cs @@ -0,0 +1,61 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.Helpers; +using YLErp.Model; + +namespace UnitTestProject.Modules +{ + /// + /// EQD-6953 疑似到期债券值域闸门:IsMaturedDegenerate / IsResultAbsurd 分支覆盖。 + /// 场景来源:UAT 060203.IB(2006年国债,2026估值日已无剩余现金流)—— + /// jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100,errCode=0"成功但退化"。 + /// 与前端 swapCalc.js::getBondCalcErrorMessage 的同款闸门保持一致口径。 + /// + [TestClass] + public class BondCalcHeplerTest + { + [TestMethod] + public void 到期退化值_三条件同时成立_命中() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m }; + Assert.IsTrue(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void 正常券_不命中_按UAT实测180205IB() + { + var r = new CalBondResult { cleanPrice = 97.43300000000002m, dirtyPrice = 100.00001369863016m, ytm = 6.738278242318886m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void ytm为0但净价非面值_不命中_真实零息平价券场景() + { + var r = new CalBondResult { cleanPrice = 99.5m, dirtyPrice = 100m, ytm = 0m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void 价格为面值但ytm非0_不命中_正常息票平价券场景() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100.5m, ytm = 3.2m }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void ytm为null_不命中() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = null }; + Assert.IsFalse(BondCalcHepler.IsMaturedDegenerate(r)); + } + + [TestMethod] + public void IsResultAbsurd_到期退化值_命中并带原因() + { + var r = new CalBondResult { cleanPrice = 100m, dirtyPrice = 100m, ytm = 0m }; + var ok = typeof(BondCalcHepler) + .GetMethod("IsResultAbsurd", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) + .Invoke(null, new object[] { r, null }); + Assert.IsTrue((bool)ok); + } + } +} diff --git a/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs new file mode 100644 index 00000000..822de107 --- /dev/null +++ b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs @@ -0,0 +1,51 @@ +using YLErp.DBModels; +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class BondPaymentServiceCalculationTest + { + private static BondPaymentService CreateService() + { + return new BondPaymentService( + new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest)); + } + + [TestMethod] + public void CalcPayment_BondCoupon_KeepsPerHundredScale() + { + var payments = new List + { + new BondPayment { payment_interest = 1m } + }; + + // 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m); + + Assert.AreEqual(10m, actual); + } + + [TestMethod] + public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale() + { + var payments = new List + { + new BondPayment { payment_interest = 10m } + }; + + // GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10; + // 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m, + useBondPriceScale: false); + + Assert.AreEqual(1000m, actual); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index e42c8de3..35aac0b8 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -69,7 +69,10 @@ namespace YLErp.Modules.SwapModule public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new(); /// 捕获落库的互换流水明细 - public List PersistedFlowEvents { get; } = new(); + public List PersistedFlowEvents => DbContext.swap_flow_event.Local.ToList(); + + /// 捕获资金流水的金额、操作类型和发生日 + public List<(double amount, string action, DateTime valueDate)> ClientCashCallDetails { get; } = new(); public AutoSwapEodService( List trades, List positions, @@ -112,6 +115,13 @@ namespace YLErp.Modules.SwapModule protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + ClientCashCallDetails.Add((amount, action, valueDate)); + return ClientCashCalls.Count; + } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) { @@ -520,6 +530,9 @@ namespace YLErp.Modules.SwapModule $"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})"); Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate), $"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日"); + Assert.AreEqual(1, svc.ClientCashCallDetails.Count, "应生成 1 条分红资金流水"); + Assert.AreEqual(actualPayDate, svc.ClientCashCallDetails[0].valueDate, + "资金发生日应使用分红支付日"); } // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs new file mode 100644 index 00000000..7e202a38 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.DBModels.Consts; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class CorporateActionEventLifecycleTest + { + // 8/14 登记日只创建 Applied=false 的待生效事件;8/17 真实生效日补齐 + // 同一事件的调整前后快照并标记 Applied=true。 + private static readonly DateTime RecordDate = new DateTime(2026, 8, 14); + private static readonly DateTime EffectiveDate = new DateTime(2026, 8, 17); + + [TestMethod] + public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields() + { + var info = CreateAction(77, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + null, + applied: false); + + Assert.AreEqual(77, snapshot.ExDividendInfoId); + Assert.AreEqual(9L, snapshot.PositionId); + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(100000m, snapshot.BeforeNotional); + Assert.AreEqual(0m, snapshot.AfterQuantity); + Assert.IsFalse(snapshot.Applied); + + var reason = SwapEventService.BuildCorporateActionEventReason(snapshot); + StringAssert.Contains(reason, "BeforeQuantity=1000"); + StringAssert.Contains(reason, "AfterQuantity=0"); + } + + [TestMethod] + public void EffectiveSnapshot_ContainsAfterFields_AndSupportsStockAndFund() + { + var info = CreateAction(78, ConsGlobal.InstrumentType.Fund); + var before = CreateEodPosition(10, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(10, info.UnderlyingCode, 2000m, 50m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + after, + applied: true); + + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(2000m, snapshot.AfterQuantity); + Assert.AreEqual(50m, snapshot.AfterPrice); + Assert.AreEqual(100000m, snapshot.AfterNotional); + Assert.IsTrue(snapshot.Applied); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Stock)); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Fund)); + Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds)); + } + + [TestMethod] + public void Rerun_DoesNotCreateDuplicateCorporateActionEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var existing = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(snapshot), + Invalid = false + }; + + Assert.IsFalse(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { existing }, + info, + 11L)); + } + + [TestMethod] + public void LegacyEventWithoutExDividendInfoId_DoesNotBlockCurrentEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var legacySnapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + legacySnapshot.ExDividendInfoId = 0; + var legacyEvent = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(legacySnapshot), + Invalid = false + }; + + Assert.IsTrue(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { legacyEvent }, + info, + 11L)); + } + + [TestMethod] + public void OperationHistory_PreservesPendingCorporateActionForAudit() + { + var info = CreateAction(80, ConsGlobal.InstrumentType.Stock); + var pendingData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(12, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var appliedData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(13, info.UnderlyingCode, 1000m, 100m), + CreateEodPosition(13, info.UnderlyingCode, 2000m, 50m), + applied: true); + var events = new List + { + new swap_event { id = 1, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(pendingData) }, + new swap_event { id = 2, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(appliedData) }, + new swap_event { id = 3, EventType = (int)SwapEventTypeEnum.互换, EventData = "{}" } + }; + + // 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。 + Assert.AreEqual(3, events.Count); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot)); + Assert.IsFalse(pendingSnapshot.Applied); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot)); + Assert.IsTrue(appliedSnapshot.Applied); + } + + [TestMethod] + public void EffectiveCorporateAction_AdjustsStockQuantityAndPrice() + { + var position = new swap_position + { + PositionId = 14, + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + UnderlyingCode = "STOCK.TEST", + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + ContractSize = 1m + }; + var info = CreateAction(81, ConsGlobal.InstrumentType.Stock); + info.GiveShareAmount = 10m; + + var applied = SwapEodPositionService.ApplyCorporateActionToPosition( + position, + info, + 100m, + 0m); + + Assert.IsTrue(applied); + Assert.AreEqual(2000m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + Assert.AreEqual(100000m, position.PosiNotionalValue); + } + + [TestMethod] + public void Lifecycle_RegistrationIsIdempotent_ThenEffectiveUpdatesSameEvent() + { + var info = CreateAction(82, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(15, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(15, info.UnderlyingCode, 2000m, 50m); + var service = new EventRecordingService(); + var trade = new trade { id = 100 }; + + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + + Assert.AreEqual(1, service.Events.Count); + Assert.AreEqual(1000m, before.PosiQuantity, "登记日不能改持仓数量"); + Assert.AreEqual(100m, before.PosiGrossPrice, "登记日不能改持仓价格"); + var pending = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsFalse(pending.Applied); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + + service.Record( + trade, + new[] { after }, + new[] { before }, + Array.Empty(), + new[] { info }, + EffectiveDate); + + Assert.AreEqual(1, service.Events.Count, "生效日应更新原事件而非新增事件"); + Assert.AreEqual(1, service.UpdateCount); + var applied = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsTrue(applied.Applied); + Assert.AreEqual(1000m, applied.BeforeQuantity); + Assert.AreEqual(2000m, applied.AfterQuantity); + Assert.AreEqual(50m, applied.AfterPrice); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + } + + private sealed class EventRecordingService : TestableSwapEodPositionService + { + public List Events { get; } = new List(); + public int UpdateCount { get; private set; } + + public EventRecordingService() + : base(nameof(CorporateActionEventLifecycleTest)) + { + } + + protected override List FindCorporateActionEvents(int swapTradeId) + { + return Events; + } + + protected override swap_event AddSwapEvent( + DateTime tradeDate, + int swapTradeId, + int eventType, + string data, + int clientCashId, + bool save, + string reason) + { + return new swap_event { id = Events.Count + 1 }; + } + + protected override void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + UpdateCount++; + } + + public void Record( + trade trade, + IReadOnlyCollection current, + IReadOnlyCollection previous, + IReadOnlyCollection registration, + IReadOnlyCollection effective, + DateTime settleDate) + { + RecordCorporateActionEvents( + trade, + current, + previous, + registration, + effective, + settleDate); + } + } + + private static ex_dividend_info CreateAction(int id, string instrumentType) + { + return new ex_dividend_info + { + id = id, + UnderlyingCode = instrumentType == ConsGlobal.InstrumentType.Fund ? "FUND.TEST" : "STOCK.TEST", + ExDividendDate = RecordDate, + EffectiveDate = EffectiveDate, + GiveShareAmount = 0m, + GiveCashAmount = 0m, + ValidStatus = true + }; + } + + private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price) + { + return new eod_swap_position + { + PositionId = positionId, + UnderlyingCode = code, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNotionalValue = quantity * price, + PosiNetPrice = price, + ContractSize = 1m, + PosiDirection = 1, + PositionType = 1 + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs new file mode 100644 index 00000000..e8d805f4 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -0,0 +1,395 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class FundCorporateActionRollbackAndUnwindTest + { + // 生产恢复范围已从原 Fund-only 扩展到 TRS Fund/Stock;本组继续使用 Fund 夹具, + // 验证共享的登记日/EffectiveDate 边界和回退、平仓基线。 + private static readonly DateTime ExDate = new(2026, 8, 17); + + [TestMethod] + public void FCA_RB_001_回退选择最近实际Eod并遵守除权日边界() + { + var friday = CreateEod(new DateTime(2026, 8, 14), 1000m, 100m); + var exDate = CreateEod(ExDate, 2000m, 50m); + var invalidSunday = CreateEod(new DateTime(2026, 8, 16), 9999m, 1m); + invalidSunday.Invalid = true; + var snapshots = new[] { friday, invalidSunday, exDate }; + + var rollbackToExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate); + var rollbackAfterExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate.AddDays(1)); + + Assert.AreEqual(friday.ValueDate, rollbackToExDate.Single().ValueDate, + "回退到除权日应恢复除权前最近实际 EOD,不能用周日自然日或除权日自身"); + Assert.AreEqual(1000m, rollbackToExDate.Single().PosiQuantity); + Assert.AreEqual(exDate.ValueDate, rollbackAfterExDate.Single().ValueDate, + "回退到除权日之后应保留已经生效的除权 EOD"); + Assert.AreEqual(2000m, rollbackAfterExDate.Single().PosiQuantity); + } + + [TestMethod] + public void FCA_UW_001_最近FundEod恢复价格数量且重复恢复不重复除权() + { + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity); + Assert.AreEqual(50m, realtime.PosiGrossPrice); + Assert.AreEqual(100000m, realtime.PosiNotionalValue); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "恢复 EOD 是复制快照,不是再次套 10 送 10 系数,不能变成 4000"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "重复恢复不能把价格再次调整为 25"); + } + + [TestMethod] + public void FCA_UW_002_股票与最新Eod后已有完成流水时保持实时持仓() + { + var nonFund = CreateRealtimeFundPosition(); + nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + var eod = CreateEod(ExDate, 2000m, 50m); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); + Assert.AreEqual(2000m, nonFund.PosiQuantity); + Assert.AreEqual(50m, nonFund.PosiGrossPrice); + + var td = SwapDealTestFactory.CreateTrade(); + var realtime = CreateRealtimeFundPosition(); + realtime.PosiQuantity = 1500m; + realtime.PosiGrossPrice = 50m; + var service = CreateService(td, realtime, eod, hasCompletedFlow: true); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsFalse(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate.AddDays(1))); + Assert.AreEqual(1500m, realtime.PosiQuantity, + "EOD 后已有部分平仓流水时不能用 2000 份 EOD 覆盖实时剩余 1500 份"); + Assert.AreEqual(1000m, unwindData.CloseQty, + "未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程"); + } + + [TestMethod] + public void FCA_UW_008_股票TRS平仓恢复有效Eod基线() + { + var realtime = CreateRealtimeFundPosition(); + realtime.UnderlyingCode = "STOCK.TEST"; + realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + realtime.PosiQuantity = 1000m; + realtime.PosiGrossPrice = 100m; + realtime.PosiNetPrice = 100m; + realtime.PosiNetFeePrice = 100m; + realtime.PosiNetNoFeePrice = 100m; + realtime.PosiNotionalValue = 100000m; + + var eod = CreateEod(ExDate, 2000m, 50m); + eod.UnderlyingCode = "STOCK.TEST"; + eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "Stock TRS 生效日盘中平仓应使用有效 EOD 价格"); + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + + [TestMethod] + public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + + Assert.AreEqual(2000m, realtime.PosiQuantity, + "8 月 17 日盘中应先从 8 月 14 日 EOD 恢复,再按 10 送 10 变为 2000 份"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "真实除权生效日盘中应使用 50 元基准,不能继续使用登记日 100 元"); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + + [TestMethod] + public void FCA_UW_006_登记日盘中平仓不提前应用除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + // 8 月 14 日盘中尚未生成当日 EOD,最近可用快照应是 8 月 13 日。 + var eod = CreateEod(recordDate.AddDays(-1), 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = recordDate; + unwindData.UnwindDate = recordDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1000m, unwindData.PositionQty, + "登记日仍使用除权前 EOD 基线,不能提前变为 2000 份"); + Assert.AreEqual(1000m, unwindData.CloseQty); + Assert.AreEqual(100m, unwindData.FlowEvents.Single().PosiGrossPrice, + "登记日盘中平仓价格仍应为 100 元,除权生效日才切换为 50 元"); + } + + [TestMethod] + public void FCA_UW_007_基金直接拆合股比例零点零一_平仓按新数量价格() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + // 上游 splitratio=0.01 必须先转换为 10 * (0.01 - 1)=-9.9; + // 直接写 0.01 会按当前字段公式得到 1.001 倍,无法表达缩小为 0.01 倍。 + GiveShareAmount = -9.9m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(10m, unwindData.PositionQty, + "Fund splitratio=0.01 时,有效平仓基线应为 1000 * 0.01 = 10 份"); + Assert.AreEqual(10m, unwindData.CloseQty); + Assert.AreEqual(10000m, unwindData.FlowEvents.Single().PosiGrossPrice, + "Fund 份额缩小为 0.01 倍时,直接平仓期初价应为 100 / 0.01 = 10000"); + } + + [TestMethod] + public void FCA_UW_009_登记日跨非交易日到生效日按范围恢复基金基线() + { + var eodDate = new DateTime(2026, 7, 12); + var effectiveDate = new DateTime(2026, 7, 13); + var unwindDate = new DateTime(2026, 7, 17); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(eodDate, 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + id = 1, + UnderlyingCode = "FUND.TEST", + // 7/10 登记,7/13 生效;7/11、7/12 虽无交易但仍可能存在未除权 EOD 快照。 + ExDividendDate = new DateTime(2026, 7, 10), + EffectiveDate = effectiveDate, + // 生产数据口径:1 拆 2 直接存 Split=2,GiveShareAmount 不参与该拆分。 + GiveShareAmount = 0m, + Split = 2m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = unwindDate; + unwindData.UnwindDate = unwindDate; + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, unwindDate)); + + Assert.AreEqual(2000m, realtime.PosiQuantity, + "7 月 17 日平仓应补应用 7 月 13 日生效的 Split=2,公司行为不能只按平仓日命中"); + Assert.AreEqual(50m, realtime.PosiGrossPrice); + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + + [TestMethod] + public void FCA_UW_003_正式平仓按FundEod基线重算PnL和现金() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + var floatEvent = unwindData.FlowEvents.Single(); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(100000m, unwindData.CloseNotionalValue); + Assert.AreEqual(50m, floatEvent.PosiGrossPrice); + Assert.AreEqual(20000m, floatEvent.MarkClosePnl, + "平仓价 60 - 除权后期初价 50,乘 2000 份,应为 20000"); + Assert.AreEqual(20000m, unwindData.SwapRealizedPnL); + Assert.AreEqual(-20000d, service.ClientCashCalls.Single().amount, 0.001d, + "客户现金必须使用后台按有效 EOD 重算后的平仓金额"); + } + + [TestMethod] + public void FCA_UW_004_现金分红后部分平仓从Eod名义本金扣减() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 1000m, 99m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -500m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 0.5m, + closeQty: 500m, + closeNotionalValue: 50000m, + positionQty: 1000m); + unwindData.NotionalValue = 100000m; + unwindData.PosiNotionalValue = 100000m; + unwindData.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 99m, + Quantity = 500m, + PositionQty = 500m, + ContractSize = 1m, + MarkClosePnl = -500m + }); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(99000m, unwindData.PosiNotionalValue); + Assert.AreEqual(49500m, unwindData.CloseNotionalValue); + Assert.AreEqual(0m, unwindData.SwapRealizedPnL, + "市场价和除权后期初价同为 99 时不应产生额外盯市损益"); + Assert.AreEqual(49500d, td.StockEqvNotional, 0.001d, + "应从 EOD 有效名义本金 99000 扣除 49500,不能从旧 trade 值 100000 扣减"); + Assert.AreEqual(500d, td.TradeAmount, 0.001d); + } + + private static TestableSwapDealService CreateService( + trade td, + swap_position realtime, + eod_swap_position eod, + bool hasCompletedFlow) + { + return new TestableSwapDealService(td) + { + RealtimeFloatPosition = realtime, + LatestFundEodPosition = eod, + HasCompletedFlowAfterLatestFundEod = hasCompletedFlow, + ActiveSwapPositions = new List { realtime } + }; + } + + private static swap_position CreateRealtimeFundPosition() + { + return new swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + IsInitial = false, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + PosiNotionalValue = 100000m, + ContractSize = 1m + }; + } + + private static eod_swap_position CreateEod(DateTime valueDate, decimal quantity, decimal price) + { + return new eod_swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + ValueDate = valueDate, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNetPrice = price, + PosiNetFeePrice = price, + PosiNetNoFeePrice = price, + UnderlyingPrice = price, + PosiNotionalValue = quantity * price, + ContractSize = 1m + }; + } + + private static UnwindData CreateFullCloseUnwindData() + { + var data = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -40000m, + closeMethod: (int)CloseMethodEnum.全部平仓, + closePercent: 1m, + closeQty: 1000m, + closeNotionalValue: 100000m, + positionQty: 1000m); + data.NotionalValue = 100000m; + data.PosiNotionalValue = 100000m; + data.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 60m, + Quantity = 1000m, + PositionQty = 0m, + ContractSize = 1m, + MarkClosePnl = -40000m + }); + return data; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs index b4e05448..bf3dd846 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -11,7 +11,9 @@ namespace UnitTestProject.Modules.SwapModule.Penalty /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); - /// ⑤ 起息日当天平仓(无 preEod)。 + /// ⑤ 起息日当天平仓(无 preEod); + /// ⑥ 部分平仓 share<1 + 无 preEod 兜底——钉 merger 复刻 GetInterests 本金口径的接缝 + /// (现有用例全部 closePercent=1m,重放基数与复刻本金的口径偏差在 share=1 下不可见)。 /// /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 @@ -49,14 +51,14 @@ namespace UnitTestProject.Modules.SwapModule.Penalty ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid" }; - private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread) + private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7) => new() { id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread, InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, - IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" }; @@ -66,17 +68,20 @@ namespace UnitTestProject.Modules.SwapModule.Penalty TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum }; private static decimal RunFee(trade td, swap_position p, decimal settledAmount, - eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread) + eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, + decimal interestPrincipal = 0m, bool maturityCalcLast = true, decimal closePercent = 1m) { var e = new swap_flow_event { PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m, - InterestDirection = 1, InterestClosePnL = settledAmount + InterestDirection = 1, InterestClosePnL = settledAmount, + InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①) }; PenaltyInterestFeeMerger.Merge( td, new List { p }, new List { e }, - unwind, AnnualDays, settled, maturityCalcLast: true, - posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, + posiNotionalValue: Notional, closePosiNotionalValue: Notional * closePercent, + closePercent: closePercent, getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); return e.InterestFee; } @@ -170,6 +175,50 @@ namespace UnitTestProject.Modules.SwapModule.Penalty "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)"); } + [TestMethod] + public void 无preEod且此前已有重置_经事件基数兜底_恒等式精确成立() + { + // UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。 + // 兜底① = normalEvent.InterestPrincipal − 本金(复利重放末次并本金后基数); + // 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19] + var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径) + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); + } + + [TestMethod] + public void 部分平仓无preEod兜底_share对齐本金口径_恒等式成立() + { + // 接缝守卫:merger 的 closePrincipal 走 CalcNotional 复刻 GetInterests 口径 + // (标的期初全价 = posiNotional×closePercent),而重放基数由调用方以 + // closePosiNotionalValue 缩放——两处口径若有偏差,share=1 时不可见、 + // share<1 时 ① 里会混入本金差。本用例以 50% 平仓钉死该对齐。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var share = 0.5m; + var closedNotional = Notional * share; + // 被平份额的实结与重放基数:复利对 notional 线性,直接按半额本金重放 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + var replayFinalBasis = closedNotional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, notional: closedNotional, period: 14); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false, closePercent: share); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, notional: closedNotional, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "部分平仓+无preEod:兜底①按被平份额缩放精确,全期(被平份额)=实结+罚息(口径漂移时此式必挂)"); + } + [TestMethod] public void 起息日当天平仓_无preEod_恒等式成立() { diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 2186bfdc..3a9ede19 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static void RunMerge( swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, - Func? getSpread = null, Func? tryGetFixing = null) + Func? getSpread = null, Func? tryGetFixing = null, + AccrualTrace? trace = null) { getSpread ??= _ => Rate; tryGetFixing ??= (d, code) => Rate; @@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: getSpread, getPreEod: _ => preEod, - tryGetFixing: tryGetFixing); + tryGetFixing: tryGetFixing, + trace: trace); } /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 @@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); } + [TestMethod] + public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() + { + // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底已并复利本金=0。 + // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应>0,已并复利本金=0 属退化—— + // 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。 + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0", + "已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + } + + [TestMethod] + public void 无preEod兜底为正_不留退化告警() + { + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → 已并复利本金=100000 正常路径 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警"); + } + + [TestMethod] + public void 无preEod真首日兜底为零_不留退化告警() + { + var p = Leg(InterestTypeEnum.复利); + p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,已并复利本金=0 是设计内约定(类头注) + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; + var trace = new AccrualTrace(); + RunMerge(p, e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警"); + } + [TestMethod] public void 冻结利率解析失败_跳过该腿不阻断() { diff --git a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs new file mode 100644 index 00000000..2cd6ba1f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class SplitCorporateActionTddTest + { + [TestMethod] + public void SplitTenScalesQuantityAndPriceByTen() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(1000m, position.PosiQuantity); + Assert.AreEqual(10m, position.PosiGrossPrice); + } + + [TestMethod] + public void SplitPointOneScalesQuantityAndPriceByPointOne() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 0.1m); + + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(10m, position.PosiQuantity); + Assert.AreEqual(1000m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareTenWithNullSplitUsesCompatibleFactorTwo() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 10m, split: null); + + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(200m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareFiveAndSplitTwoHaveCombinedFactorThree() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 5m, split: 2m); + + // (1 + 5 / 10) * 2 = 3:100 份/100 元变为 300 份/约 33.333333333 元。 + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(300m, position.PosiQuantity); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 33.333333333m) < 0.000000001m); + } + + [TestMethod] + public void CashAmountDoesNotChangeTrsFundInitialPriceFactor() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(cash: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(100m, position.PosiQuantity); + Assert.AreEqual(100m, position.PosiGrossPrice); + } + + [TestMethod] + public void RationedSharesUseExcelPriceRatioForTrsQuantity() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction( + rationedSharesAmount: 1m, + rationedSharesPrice: 50m); + + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( + position, info, 100m, 0m)); + + // Excel L-N:L=(100*10+1*50)/(10+1)=95.4545...,M=100/L; + // 因此数量和价格分别按 Q'=Q*M、P'=P/M 调整。 + Assert.IsTrue(Math.Abs(position.PosiQuantity - 104.761904761905m) < 0.000000000001m); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 95.454545455m) < 0.000000001m); + } + + [TestMethod] + public void ZeroSplitIsRejected() + { + var info = CreateCorporateAction(split: 0m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void NegativeSplitIsRejected() + { + var info = CreateCorporateAction(split: -1m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void MissingSplitDoesNotClearExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: null); + + InvokeMerge(target, source); + + Assert.AreEqual(10m, GetSplit(target)); + } + + [TestMethod] + public void ExplicitSplitOneOverridesExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: 1m); + + InvokeMerge(target, source); + + Assert.AreEqual(1m, GetSplit(target)); + } + + private static ex_dividend_info CreateCorporateAction( + decimal cash = 0m, + decimal giveShare = 0m, + decimal? split = null, + decimal rationedSharesAmount = 0m, + decimal rationedSharesPrice = 0m) + { + var info = new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = new DateTime(2026, 8, 14), + EffectiveDate = new DateTime(2026, 8, 17), + GiveCashAmount = cash, + GiveShareAmount = giveShare, + RationedSharesAmount = rationedSharesAmount, + RationedSharesPrice = rationedSharesPrice, + ValidStatus = true + }; + SetSplit(info, split); + return info; + } + + private static swap_position CreateFundPosition() + { + return new swap_position + { + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + UnderlyingCode = "FUND.TEST", + PosiQuantity = 100m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + ContractSize = 1m + }; + } + + private static void SetSplit(ex_dividend_info info, decimal? value) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + property.SetValue(info, value); + } + + private static decimal? GetSplit(ex_dividend_info info) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + return (decimal?)property.GetValue(info); + } + + private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source) + { + var method = typeof(DividendService).GetMethod( + "MergeNonZeroDividendValues", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(method, "公司行为存量合并方法不存在"); + method.Invoke(null, new object[] { target, source }); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 708ac5cf..c71b5569 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -57,6 +57,7 @@ namespace YLErp.Modules.SwapModule protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; protected override List FindCompletedFlowEvents(List tradeIds) => _flowEvents; + public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) => null; protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); protected override List FindSwapPositions(int swapTradeId) @@ -93,6 +94,16 @@ namespace YLErp.Modules.SwapModule public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); + + public void ExecuteFundCorporateActions( + IReadOnlyCollection positions, + IReadOnlyCollection dividendInfos) + { + ApplyCorporateActions( + positions, + dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), + SettleDate); + } } #endregion @@ -144,7 +155,7 @@ namespace YLErp.Modules.SwapModule PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, - UnderlyingCode = "220205.IB", ContractSize = 1m, + UnderlyingCode = "220205.IB", UnderlyingPrice = grossPrice, ContractSize = 1m, InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty }; } @@ -161,6 +172,41 @@ namespace YLErp.Modules.SwapModule }; } + private static ex_dividend_info CreateFundCorporateAction( + decimal cashAmount = 0m, + decimal shareAmount = 0m) + { + return new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = SettleDate, + EffectiveDate = SettleDate, + GiveCashAmount = cashAmount, + GiveShareAmount = shareAmount, + ValidStatus = true + }; + } + + private static void SetFundLeg(swap_position position, eod_swap_position previousEod) + { + position.UnderlyingCode = "FUND.TEST"; + position.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + position.PosiGrossPrice = 100m; + position.PosiNetPrice = 102m; + position.PosiNetFeePrice = 104m; + position.PosiNetNoFeePrice = 106m; + + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = position.UnderlyingInstrumentType; + previousEod.PosiGrossPrice = position.PosiGrossPrice; + previousEod.PosiNetPrice = position.PosiNetPrice; + previousEod.PosiNetFeePrice = position.PosiNetFeePrice; + previousEod.PosiNetNoFeePrice = position.PosiNetNoFeePrice; + previousEod.PosiNotionalValue = previousEod.PosiGrossPrice + * previousEod.PosiQuantity + * previousEod.ContractSize; + } + #endregion // ================================================================ @@ -238,6 +284,275 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); } + [TestMethod] + public void SPC_FUND_001_送股除权_调整价格数量并重算持仓结果() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + service.ExDividendInfos); + + Assert.AreEqual(2000m, actual.PosiQuantity); + Assert.AreEqual(1000m, actual.TdChangedQty); + Assert.AreEqual(50m, actual.PosiGrossPrice); + Assert.AreEqual(51m, actual.PosiNetPrice); + Assert.AreEqual(52m, actual.PosiNetFeePrice); + Assert.AreEqual(53m, actual.PosiNetNoFeePrice); + Assert.AreEqual(100000m, actual.PosiNotionalValue); + Assert.AreEqual(200000m, actual.UnderlyingMarketValue); + Assert.AreEqual(100000m, actual.PosiMtmPnL); + Assert.AreEqual(100000m, actual.PosiProfitSum); + } + + [TestMethod] + public void SPC_FUND_002_现金分红_登记日不直接入账() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); + + // 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息 + // 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。 + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(0m, actual.TdChangedQty); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdPosiDividend); + Assert.AreEqual(0m, actual.PosiDividendSum); + Assert.AreEqual(100000m, actual.PosiNotionalValue); + Assert.AreEqual(0m, actual.PosiMtmPnL); + Assert.AreEqual(0m, actual.PosiProfitSum); + Assert.AreEqual(0m, actual.RealizedDividend); + Assert.AreEqual(0m, actual.RealizedPnl); + } + + [TestMethod] + public void SPC_FUND_003_同日重跑_从前日基线重算不重复除权() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + // 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为; + // 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。 + var firstRunEod = previousEod.Clone(); + firstRunEod.ValueDate = SettleDate; + firstRunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos); + + var rerunEod = previousEod.Clone(); + rerunEod.ValueDate = SettleDate; + rerunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos); + + Assert.AreEqual(2000m, firstRunEod.PosiQuantity); + Assert.AreEqual(1000m, firstRunEod.TdChangedQty); + Assert.AreEqual(50m, firstRunEod.PosiGrossPrice); + Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue); + Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity); + Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice); + } + + [TestMethod] + public void SPC_FUND_004_非Fund标的_即使命中公司行为也不调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + position.UnderlyingCode = "FUND.TEST"; + position.PosiGrossPrice = 100m; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = "TBonds"; + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + service.ExDividendInfos); + + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdChangedQty); + } + + [TestMethod] + public void SPC_FUND_005_同日同代码多条有效记录_明确失败() + { + var service = new TestableSwapEodService( + new List { CreateTrade() }, + new List(), + new List(), + new List(), + new List { CreateExtend() }, + new List()); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 1m)); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 1m)); + + var exception = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + + StringAssert.Contains(exception.Message, "存在多条有效除权记录"); + } + + [TestMethod] + public void SPC_FUND_006_登记日Eod保持除权前数量价格_生效日才调整() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(recordDate, PreSettleDate); + + var recordEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1000m, recordEod.PosiQuantity, + "登记日 EOD 仍展示除权前数量,不能提前变成 2000"); + Assert.AreEqual(100m, recordEod.PosiGrossPrice, + "登记日 EOD 仍展示除权前价格,不能提前变成 50"); + } + + [TestMethod] + public void SPC_FUND_007_生效日先以除权后基线处理平仓_1000平300得到1700份50元() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var initialPosition = CreateFloatPosition(1, 1000m); + var realtimePosition = initialPosition.Clone(); + realtimePosition.id = 2; + realtimePosition.IsInitial = false; + realtimePosition.PositionId = initialPosition.id; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.ValueDate = recordDate; + SetFundLeg(initialPosition, previousEod); + SetFundLeg(realtimePosition, previousEod); + var closeFlow = CreateCloseFlowEvent(initialPosition.id, 300m); + closeFlow.UnderlyingCode = "FUND.TEST"; + closeFlow.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + closeFlow.DividendIn = 0m; + var service = new TestableSwapEodService( + new List { td }, + new List { initialPosition, realtimePosition }, + new List { previousEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = recordDate } }, + new List { CreateExtend() }, + new List { closeFlow }, + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(effectiveDate, recordDate); + + var effectiveEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1700m, effectiveEod.PosiQuantity, + "生效日先把 1000 份变为 2000 份,再平仓 300 份,应剩 1700 而非 1400"); + Assert.AreEqual(50m, effectiveEod.PosiGrossPrice, + "10 送 10 后期初价格应为 50"); + } + + [TestMethod] + public void SPC_FUND_008_上游splitratio零点零一映射GiveShareAmount负九点九_Eod数量价格调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + // 上游 splitratio=sharesafter/sharesbefore=0.01,落库前按 + // GiveShareAmount=10*(splitratio-1) 转换为 -9.9;现有公式因此得到 0.01 倍。 + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: -9.9m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + service.ExDividendInfos); + + Assert.AreEqual(10m, actual.PosiQuantity, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,1000 份应调整为 10 份"); + Assert.AreEqual(10000m, actual.PosiGrossPrice, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,期初价格应反向放大 100 倍"); + } + // ================================================================ // 场景4:未收盘抛异常 // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 8b404f7e..3e852ba8 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -24,6 +24,13 @@ namespace YLErp.Modules.SwapModule public int SaveAllChangesCount; public int CloseReCheckCallCount; + /// Fund 盤中基线测试输入;生产服务通过数据库查询同名 seam。 + public swap_position RealtimeFloatPosition { get; set; } + public eod_swap_position LatestFundEodPosition { get; set; } + public bool HasCompletedFlowAfterLatestFundEod { get; set; } + public List ActiveSwapPositions { get; set; } = new(); + public List ExDividendInfos { get; } = new(); + public TestableSwapDealService(trade td, Dictionary swapEvents = null, Dictionary> flowEventsByEventId = null) @@ -36,6 +43,45 @@ namespace YLErp.Modules.SwapModule protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; + protected override List FindActiveSwapPositions(int tradeId) + => ActiveSwapPositions; + + protected override swap_position FindRealtimeFloatPosition(UnwindData unwindData) + => RealtimeFloatPosition; + + protected override eod_swap_position FindLatestFundEodPosition(int tradeId, long positionId, DateTime valueDate) + => LatestFundEodPosition; + + protected override bool HasCompletedFlowAfterFundEod(int tradeId, long positionId, DateTime eodDate, DateTime valueDate) + => HasCompletedFlowAfterLatestFundEod; + + protected override ex_dividend_info FindFundCorporateAction(string underlyingCode, DateTime valueDate) + => ExDividendInfos.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate == valueDate.Date); + + protected override List FindFundCorporateActions( + string underlyingCode, + DateTime eodDate, + DateTime valueDate) + => ExDividendInfos + .Where(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date > eodDate.Date + && x.EffectiveDate.Value.Date <= valueDate.Date) + .OrderBy(x => x.EffectiveDate) + .ThenBy(x => x.id) + .ToList(); + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + + public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) + => TryRestoreAndValidateUnwindData(unwindData, valueDate); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action, valueDate)); diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index ce5faa45..38128373 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -42,6 +42,12 @@ namespace YLErp.Modules.SwapModule /// AddClientCash 调用记录(金额, 操作) public List<(double amount, string action)> ClientCashCalls { get; } = new(); + /// SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。 + public List ExDividendInfos { get; } = new(); + + /// 捕获公司行为生命周期事件,避免事件测试访问真实 swap_event 表。 + public List CorporateActionEvents { get; } = new(); + /// 自增 id 模拟器(新增 eod 时分配 id) private int _nextId = 1; @@ -78,6 +84,28 @@ namespace YLErp.Modules.SwapModule return 1.0; // 本币,汇率=1 } + protected override List FindCorporateActionInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && (x.ExDividendDate?.Date == settleDate.Date + || x.EffectiveDate?.Date == settleDate.Date)) + .ToList(); + } + + protected override List FindCorporateActionEvents(int swapTradeId) + { + return CorporateActionEvents + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid + && x.EventType == (int)SwapEventTypeEnum.公司行为) + .ToList(); + } + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action)); diff --git a/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs b/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs new file mode 100644 index 00000000..6825c5ac --- /dev/null +++ b/UnitTestProject/Modules/UnderlyingModule/UnderlyingFundManagerMappingTest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; + +namespace YLErp.UnitTestProject.Modules.UnderlyingModule +{ + [TestClass] + public class UnderlyingFundManagerMappingTest + { + [TestMethod] + public void InvestAdvisorName_MapsExistingFundManagerColumn() + { + var property = typeof(underlying_manager).GetProperty("InvestAdvisorName"); + + Assert.IsNotNull(property, "underlying_manager 应公开基金管理人属性 InvestAdvisorName"); + Assert.AreEqual("investadvisorname", property.GetCustomAttribute()?.Name); + Assert.AreEqual("基金管理人", property.GetCustomAttribute()?.DisplayName); + } + } +} diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index 78936a0c..eff0b712 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -406,6 +406,7 @@ namespace YLErp.BLL public DbSet clientMarginConfig { get; set; } public DbSet clientMarginDetail { get; set; } public DbSet tradeContractOaResult { get; set; } + public DbSet tradeApprovalOaResult { get; set; } public DbSet bondPayment { get; set; } public DbSet glms_risk_rule { get; set; } @@ -414,4 +415,4 @@ namespace YLErp.BLL public DbSet glms_risk_variable { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Helpers/BondCalcHepler.cs b/YLErpDAL/Helpers/BondCalcHepler.cs index 4f7acd21..ed6b6bcd 100644 --- a/YLErpDAL/Helpers/BondCalcHepler.cs +++ b/YLErpDAL/Helpers/BondCalcHepler.cs @@ -162,7 +162,8 @@ namespace YLErp.Helpers /// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则: /// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 >1000 视为离谱); /// - 收益率(ytm,百分数口径):|ytm| > 100 视为爆炸(允许负利率债,但量级须合理); - /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。 + /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传); + /// - 疑似到期/无剩余现金流:见 。 /// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。 /// private static bool IsResultAbsurd(CalBondResult data, out string reason) @@ -186,8 +187,27 @@ namespace YLErp.Helpers reason = "债券计算收益率量级异常(" + data.ytm.Value + "),请检查估值日/价格输入或联系管理员核对债券计算服务"; return true; } + if (IsMaturedDegenerate(data)) + { + reason = "该债券疑似已到期或无剩余现金流(收益率=0、净价/全价均为面值100),无法反算,请核对债券到期日或手工填写"; + return true; + } return false; } + + /// + /// 疑似到期/无剩余现金流债券:估值日晚于到期日时底层 jquantlib 对空现金流求解得 ytm=0、 + /// 净价/全价均为面值100——三者同时成立在现实在市券中几乎不可能(UAT 060203.IB 实证)。 + /// 判定条件与前端 swapCalc.js::getBondCalcErrorMessage 内同款闸门保持一致,勿单边改动。 + /// public 供单测直接覆盖(BondCalcHeplerTest)。 + /// + public static bool IsMaturedDegenerate(CalBondResult data) + { + return data != null + && data.ytm == 0m + && data.cleanPrice == 100m + && data.dirtyPrice == 100m; + } } } diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index bbf76a41..3e5305ff 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -22,6 +22,14 @@ namespace YLErp.DBModels [DisplayName("股权登记日")] public DateTime? ExDividendDate { get; set; } + /// + /// 真实除权生效日。 + /// ExDividendDate 表示登记日,EffectiveDate 表示从哪个 EOD 起数量/价格基线 + /// 才允许切换到除权后口径;两者可能因周末、节假日或公告安排而不同。 + /// + [DisplayName("真实除权日")] + public DateTime? EffectiveDate { get; set; } + /// /// 税率 /// @@ -48,6 +56,13 @@ namespace YLErp.DBModels [DisplayName("送股股数")] public decimal GiveShareAmount { get; set; } + + /// + /// 拆/合股倍数。为空时按 1 兼容历史记录;与 GiveShareAmount 的“每 10 份送股数量”语义不同。 + /// + [DisplayName("拆/合股倍数")] + public decimal? Split { get; set; } + /// /// 配股手数 /// @@ -96,5 +111,10 @@ namespace YLErp.DBModels /// 股权登记日 /// public DateTime? ExDividendDate { get; set; } + + /// + /// 真实除权生效日。 + /// + public DateTime? EffectiveDate { get; set; } } } diff --git a/YLErpDAL/Model/TradeLinq.cs b/YLErpDAL/Model/TradeLinq.cs index 4862eabc..5234e178 100644 --- a/YLErpDAL/Model/TradeLinq.cs +++ b/YLErpDAL/Model/TradeLinq.cs @@ -64,6 +64,7 @@ namespace YLErp.Model public int? ProcessRoleId { get; set; } public string ProcessRoleName { get; set; } + public string OaRemark { get; set; } /// /// 验证当前审批角色是否有开仓(交易)复核权限 diff --git a/YLErpDAL/Modules/AppModule/AppUpgrader.cs b/YLErpDAL/Modules/AppModule/AppUpgrader.cs index 583afcb1..8fe40d72 100644 --- a/YLErpDAL/Modules/AppModule/AppUpgrader.cs +++ b/YLErpDAL/Modules/AppModule/AppUpgrader.cs @@ -423,6 +423,8 @@ namespace YLErp.Modules.AppModule var exists = adminDb.Dictionaries.Select(n => n.Name).ToArray() .Select(n => n.Trim()).ToHashSet(); + // 记录本次新建的字典,确保初始项只写入一次,不覆盖后续人工维护结果。 + var addedDictionaryNames = new HashSet(); var root = XElement.Parse(YLErp.Resources.Properties.Resources.db_dictionaries); var itemsAll = root.Elements(); @@ -450,10 +452,51 @@ namespace YLErp.Modules.AppModule Name = name, Catalog = catalog }); + addedDictionaryNames.Add(name); } adminDb.SaveChanges(); + // 重点功能:解析 XML 中的初始项,仅为本次首次创建的字典生成 DictionaryItem。 + foreach (var dictionaryElement in itemsAll.Where(item => + item.Elements().Any() && addedDictionaryNames.Contains(item.Attribute("name")?.Value.TrimToEmpty()))) + { + var dictionaryName = dictionaryElement.Attribute("name")?.Value.TrimToNull(); + var dictionary = adminDb.Dictionaries.FirstOrDefault(item => item.Name == dictionaryName); + if (dictionary == null) + { + continue; + } + + var existingItemNames = adminDb.DictionaryItems + .Where(item => item.DictId == dictionary.Id) + .Select(item => item.Name) + .ToHashSet(); + var nextDictionaryItemIndex = adminDb.DictionaryItems + .Where(item => item.DictId == dictionary.Id) + .Max(item => (int?)item.IndexNum) ?? -1; + + // XML 中的排列顺序写入 IndexNum,页面下拉按该顺序展示。 + foreach (var itemElement in dictionaryElement.Elements()) + { + var itemName = itemElement.Attribute("name")?.Value.TrimToNull(); + if (itemName == null || existingItemNames.Contains(itemName)) + { + continue; + } + + adminDb.DictionaryItems.Add(new BaseOUDAL.DictionaryItem + { + DictId = dictionary.Id, + Name = itemName, + ShortName = itemElement.Attribute("short_name")?.Value.TrimToNull() ?? itemName, + IndexNum = ++nextDictionaryItemIndex + }); + existingItemNames.Add(itemName); + } + } + adminDb.SaveChanges(); + var marginTemplateDictionary = adminDb.Dictionaries.FirstOrDefault(item => item.Name == YLErp.Modules.SwapModule.SwapMarginTemplateConfigService.DictionaryName); if (marginTemplateDictionary == null) { diff --git a/YLErpDAL/Modules/AppModule/EmailTemplateService.cs b/YLErpDAL/Modules/AppModule/EmailTemplateService.cs index c98fb9d8..aeca2cbc 100644 --- a/YLErpDAL/Modules/AppModule/EmailTemplateService.cs +++ b/YLErpDAL/Modules/AppModule/EmailTemplateService.cs @@ -74,7 +74,12 @@ namespace YLErp.Modules.AppModule } /// - /// 生成邮件信息,如果template为null,返回null + /// 生成邮件信息,如果template为null,返回null。 + /// 【设计决策·勿单边"升级"】占位符为朴素文本替换(非模板引擎),与本表另一消费者 + /// bond-oms Java SwapEmailHandler.replaceMailPlaceholders 保持同构渲染语义—— + /// 单边引入 FreeMarker/Thymeleaf 等更强语法或 HTML 转义,会造成两侧发出内容不一致。 + /// 替换值(客户名称/文档编号等)不做 HTML 转义是有意为之:均为内部维护的可信数据; + /// 若未来要拼接用户自由输入的内容,必须两侧同步加转义。新占位符同样两侧同步加白名单。 /// public static MailInfoResultModel GenerateMailInfo(EmailTemplate template, MailInfoRequestModel reqModel) { diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 549af3ad..9605de64 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Bibliography; using ExcelDataReader.Log; using YLErp.DBModels; @@ -104,7 +104,53 @@ namespace YLErp.Modules.EodModule .Where(x => x.reg_date > startDate && x.reg_date <= endDate) .AsNoTracking().ToList(); Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " + - string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd")))); + string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd")))); + + // 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。 + // 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储, + // 即直接存 ex_dividend_info.GiveCashAmount 原值,不做 /10; + // 最后的 /10 由 CalcPayment 非债券分支完成。 + // 去重:镜像任务完成后的正式记录带 jsid = -dividend.id;此处先按该负号标记, + // 或用“同一实际付息日 + 同一派现金额”兜底去重,避免镜像完成后重复计息。 + var corporatePayments = (from dividend in DbContext.ex_dividend_info.AsNoTracking() + join underlying in DbContext.underlying_manager.AsNoTracking() + on dividend.UnderlyingCode equals underlying.UnderlyingCode + where dividend.ValidStatus + && dividend.EffectiveDate.HasValue + && dividend.EffectiveDate.Value > startDate + && dividend.EffectiveDate.Value <= endDate + && dividend.GiveCashAmount != 0 + && dividend.UnderlyingCode == underlyingCode + && (underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock + || underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + select dividend).ToList(); + foreach (var dividend in corporatePayments) + { + // 按“每 10 份派现金额”口径,直接存 GiveCashAmount 原值,与同步任务/CalcPayment 保持一致。 + var paymentInterest = dividend.GiveCashAmount; + var hasMirroredPayment = result.Any(payment => + payment.jsid == -dividend.id + || (payment.payment_date.HasValue + && payment.payment_date.Value.Date == dividend.EffectiveDate.Value.Date + && payment.payment_interest == paymentInterest)); + if (hasMirroredPayment) + { + continue; + } + + result.Add(new BondPayment + { + underlyingCode = dividend.UnderlyingCode, + payment_date_pl = dividend.EffectiveDate, + payment_date = dividend.EffectiveDate, + payment_interest = paymentInterest, + paying_price = paymentInterest, + channel_source = ExDividendDataSources.Manual, + jsid = -dividend.id, + create_time = dividend.OptDate, + update_time = dividend.OptDate + }); + } return result; } @@ -132,24 +178,50 @@ namespace YLErp.Modules.EodModule /// 多空方向 /// 收支方向 /// - public decimal CalcPayment(string underlyingCode, DateTime startDate, DateTime endDate, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + string underlyingCode, + DateTime startDate, + DateTime endDate, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var payments = GetBondPayments(underlyingCode, startDate, endDate); - return CalcPayment(payments, qty, longRatio, payDirection); + return CalcPayment(payments, qty, longRatio, payDirection, useBondPriceScale); } /// - /// 计算某债券期间付息 + /// 计算某标的期间现金流。债券与 Stock/Fund 公司行为共用 bond_payment_info, + /// 但通过 useBondPriceScale 明确区分两种入库金额单位。 /// /// 期间付息集合 /// 持仓数量 /// 多空方向 /// 收支方向 + /// + /// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息, + /// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红 + /// 在 bond_payment_info 中按“每 10 份派现金额”存储,payment_interest * qty / 10 才是实际现金, + /// 不能再套债券的 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 + /// /// - public decimal CalcPayment(List payments, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + List payments, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var interest = payments.Sum(s => s.payment_interest ?? 0); - // interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额(与入库价格 bondPriceMultiple 同口径) - return BondPriceConverter.ToStorage(interest * qty) * longRatio * payDirection; + var paymentAmount = interest * qty; + // 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。 + // Fund/Stock 公司行为:payment_interest 存的是“每 10 份派现金额”(GiveCashAmount 原值), + // interest × qty 得到“每 10 份派现额 × 持仓份数”,需再 ÷10 才是实际现金; + // 既不能套债券的 /100,也不能直接返回 paymentAmount(那样会放大 10 倍)。 + var actualAmount = useBondPriceScale + ? BondPriceConverter.ToStorage(paymentAmount) + : paymentAmount / 10; + return actualAmount * longRatio * payDirection; } } diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 636481d8..59b64956 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -12,9 +12,9 @@ namespace YLErp.Modules.SwapModule.Penalty; /// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 /// /// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: -/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; -/// 承接② carryIn = 正常平仓流实结 InterestAmount − ① —— 最近重置日后实际已计利息; -/// 无 preEod(首日平仓):①=0、②=实结金额。 +/// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; +/// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount − 已并复利本金 —— 最近重置日后实际已计利息; +/// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。 /// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 /// public static class PenaltyInterestFeeMerger @@ -48,11 +48,11 @@ public static class PenaltyInterestFeeMerger foreach (var position in fundingPositions) { - // 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 + // 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标 var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); if (normalEvent == null) { - trace?.Note($"PENALTY|p{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); + trace?.Note($"PENALTY|融资腿{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); continue; } @@ -78,37 +78,24 @@ public static class PenaltyInterestFeeMerger frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( position, getSpread(position), preEod?.FloatRate, unwindDate, d => tryGetFixing(d, position.FloatRateUnderlyingCode)); - rateSource = preEod != null - ? $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}" - : "定盘取价(unwindDate-1区间)"; + // 来源标签须反映实际路径:固定腿不取价(利差即冻结利率);浮动腿才有快照/取价之分 + if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) + rateSource = "固定腿利差(不取价)"; + else if (preEod != null) + rateSource = $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}"; + else + rateSource = "定盘取价(unwindDate-1区间)"; } catch (Exception ex) { - trace?.Note($"PENALTY|p{position.id} 跳过 冻结利率解析失败:{ex.Message}"); + trace?.Note($"PENALTY|融资腿{position.id} 跳过 冻结利率解析失败:{ex.Message}"); continue; } - // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日(数据契约): - // 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; - // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 - // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 - decimal capitalized = 0m, carryIn = 0m; - if (isCompound) - { - var periodDays = position.interest_rest_days ?? 1; - var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); - capitalized = unwindOnResetDay - ? (preEod?.InterestIncomeSum ?? 0m) * share - : Math.Max(0m, (preEod?.TdInterestPrincipal ?? 0m) * share - closePrincipal); - // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) - if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) - { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); - capitalized = Math.Max(0m, normalEvent.InterestAmount); - } - carryIn = normalEvent.InterestAmount - capitalized; - } + // 复利承接:实际滚动基数中已并入部分(已并复利本金)+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。 + var (capitalized, carryIn) = isCompound + ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) + : (0m, 0m); var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate @@ -124,12 +111,75 @@ public static class PenaltyInterestFeeMerger normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection); trace?.Note( - $"PENALTY|p{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + + $"PENALTY|融资腿{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + - $"承接①={capitalized:F4} ②={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + + $"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } + + /// + /// 复利承接量:已并复利本金(实际滚动基数中已并入部分)+ 段内已计利息(最近重置日后实际已计,= 实结 − 已并复利本金)。 + /// + /// 已并复利本金的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+已并复利本金),直接作差; + /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + /// 会把它写为末次并本金后的基数(=被平份额本金+已并复利本金),同样是实际值而非推导值; + /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 + /// + private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry( + swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod, + decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace) + { + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + + var capitalized = 0m; + if (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod != null) + trace?.Note( + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → 已并复利本金={capitalized:F4}"); + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:已并复利本金退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + // 段中平仓+有快照(复利承接主路径):已并复利本金 = 快照滚动基数×份额 − 平仓本金。全程留推导—— + // 结果异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 + var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal; + capitalized = Math.Max(0m, rawCarry); + trace?.Note( + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → 已并复利本金={capitalized:F4}" + + (rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal)" : "")); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + trace?.Note( + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → 已并复利本金={capitalized:F4}"); + // 兜底已并复利本金=0 但账龄已过重置周期:复利每周期并本,理应 >0——多为 interestWindowEmpty + // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 + var ageDays = (unwindDate - position.PosiStartDate).Days; + if (capitalized == 0m && ageDays >= periodDays) + trace?.Note( + $"PENALTY|融资腿{position.id} 注意 无preEod兜底已并复利本金=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); + } + + // 已并复利本金不得超过实结金额(数据异常时钳制并留痕,避免负的段内已计利息进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|融资腿{position.id} 注意 承接已并复利本金钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + return (capitalized, normalEvent.InterestAmount - capitalized); + } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 0a238637..e42d0602 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -154,6 +154,301 @@ namespace YLErp.Modules.SwapModule return td.ExerciseDate.Value.AddDays(-1); } + /// 查询交易当前有效的初始腿和实时腿。测试可返回内存快照,避免初始化测试触库。 + protected virtual List FindActiveSwapPositions(int tradeId) + { + return DbContext.swap_position + .Where(x => x.SwapTradeId == tradeId && !x.Invalid) + .ToList(); + } + + /// + /// 找到平仓数据对应的实时浮动腿。正式路径以 PositionId 绑定,缺失时才按标的代码兜底; + /// 这样后台不会把前端传入的价格当成权威基线。测试可 override 为内存持仓。 + /// + protected virtual swap_position FindRealtimeFloatPosition(UnwindData unwindData) + { + if (unwindData == null) + { + return null; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var query = DbContext.swap_position + .Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.IsInitial && !x.Invalid + && !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatEvent?.PositionId > 0) + { + var byPositionId = query.FirstOrDefault(x => x.PositionId == floatEvent.PositionId); + if (byPositionId != null) + { + return byPositionId; + } + } + if (!string.IsNullOrEmpty(floatEvent?.UnderlyingCode)) + { + var byCode = query.FirstOrDefault(x => x.UnderlyingCode == floatEvent.UnderlyingCode); + if (byCode != null) + { + return byCode; + } + } + return query.FirstOrDefault(); + } + + /// 查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。 + protected virtual eod_swap_position FindLatestFundEodPosition( + int tradeId, + long positionId, + DateTime valueDate) + { + return new SwapEodPositionService(this) + .GetLatestValidEodPosition(tradeId, positionId, valueDate); + } + + /// + /// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。 + /// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有 + /// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。 + /// + protected virtual ex_dividend_info FindFundCorporateAction( + string underlyingCode, + DateTime valueDate) + { + return DbContext.ex_dividend_info.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value == valueDate.Date); + } + + /// + /// 查询从最近 EOD 之后到平仓日已经生效的 Stock/Fund 公司行为。 + /// 平仓可能跨越登记日、生效日和多个非交易日,因此不能只按 valueDate 命中一条记录。 + /// 调用方已限定为 Stock/Fund 浮动腿;同日多条记录按生效日、主键稳定排序后逐条应用。 + /// + /// 查询范围说明: + /// - 严格 > eodDate(开区间):EOD 快照本身已经是除权后结果,不能再次套用 + /// - 范围示例:eodDate=8/14(除权后 2000/50),valueDate=8/20,则查询 (8/14, 8/20] 内的记录 + /// + protected virtual List FindFundCorporateActions( + string underlyingCode, + DateTime eodDate, + DateTime valueDate) + { + var fromDate = eodDate.Date; + var toDate = valueDate.Date; + var corporateActions = DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date > fromDate + && x.EffectiveDate.Value.Date <= toDate) + .OrderBy(x => x.EffectiveDate) + .ThenBy(x => x.id) + .ToList(); + + Logger.Info($"[公司行为查询] 标的={underlyingCode} EOD={eodDate:yyyy-MM-dd} 平仓日={valueDate:yyyy-MM-dd} 查询到{corporateActions.Count}条公司行为"); + return corporateActions; + } + + /// + /// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 + /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + /// + /// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换, + /// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。 + /// + protected virtual bool HasCompletedFlowAfterFundEod( + int tradeId, + long positionId, + DateTime eodDate, + DateTime valueDate) + { + var asOfDate = valueDate.Date; + return DbContext.swap_flow_event.Any(x => x.SwapTradeId == tradeId + && x.PositionId == positionId + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate > eodDate + && x.EventDate <= asOfDate); + } + + /// + /// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。 + /// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50 + /// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。 + /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无 + /// EOD 和固定/利息腿均返回 false,沿用原逻辑。 + /// + protected virtual bool TryRestorePositionFromEod( + swap_position position, + DateTime valueDate) + { + // 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 + // 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值, + // 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。 + if (position == null + || position.PosiDirection <= 0 + || !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType)) + { + Logger.Info($"[公司行为恢复] 跳过非适用场景 positionId={position?.PositionId} direction={position?.PosiDirection} instrumentType={position?.UnderlyingInstrumentType}"); + return false; + } + + var eodPosition = FindLatestFundEodPosition( + position.SwapTradeId, + position.PositionId, + valueDate); + if (eodPosition == null) + { + Logger.Info($"[公司行为恢复] 未找到有效EOD tradeId={position.SwapTradeId} positionId={position.PositionId} valueDate={valueDate:yyyy-MM-dd}"); + return false; + } + + // 验证 EOD 数据完整性 + if (eodPosition.PosiQuantity <= 0 || eodPosition.PosiGrossPrice <= 0) + { + Logger.Info($"[公司行为恢复] EOD快照数据异常 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} qty={eodPosition.PosiQuantity} price={eodPosition.PosiGrossPrice}"); + return false; + } + + if (HasCompletedFlowAfterFundEod( + position.SwapTradeId, + position.PositionId, + eodPosition.ValueDate, + valueDate)) + { + Logger.Info($"[公司行为恢复] EOD后已有完成流水,保持实时持仓 tradeId={position.SwapTradeId} positionId={position.PositionId} eodDate={eodPosition.ValueDate:yyyy-MM-dd}"); + return false; + } + + Logger.Info($"[公司行为恢复] 从EOD恢复基线 tradeId={position.SwapTradeId} positionId={position.PositionId} " + + $"eodDate={eodPosition.ValueDate:yyyy-MM-dd} eodQty={eodPosition.PosiQuantity} eodPrice={eodPosition.PosiGrossPrice}"); + + if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition)) + { + return false; + } + + // 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线, + // 不能再次套系数。若平仓跨过多个生效日,则按生效日、id 顺序逐条补齐。 + var corporateActions = FindFundCorporateActions( + position.UnderlyingCode, + eodPosition.ValueDate, + valueDate); + + foreach (var corporateAction in corporateActions ?? new List()) + { + Logger.Info($"[公司行为应用] 除权前 id={corporateAction.id} " + + $"登记日={corporateAction.ExDividendDate:yyyy-MM-dd} " + + $"生效日={corporateAction.EffectiveDate:yyyy-MM-dd} " + + $"标的={position.UnderlyingCode} Q={position.PosiQuantity} P={position.PosiGrossPrice}"); + + var closePrice = GetFundCorporateActionClosePrice( + corporateAction, + position.PosiGrossPrice); + if (closePrice <= 0) + { + throw new ServiceException( + $"Stock/Fund 标的【{position.UnderlyingCode}】" + + $"登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】" + + $"生效日【{corporateAction.EffectiveDate:yyyy-MM-dd}】" + + $"缺少有效收盘价(id={corporateAction.id}),无法执行除权"); + } + + // TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。 + // var dividendTaxRate = GetFundDividendTaxRate(); + SwapEodPositionService.ApplyCorporateActionToPosition( + position, + corporateAction, + closePrice, + 0m); + + Logger.Info($"[公司行为应用] 除权后 id={corporateAction.id} Q={position.PosiQuantity} P={position.PosiGrossPrice} notional={position.PosiNotionalValue}"); + } + + return true; + } + + /// + /// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金, + /// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。 + /// + protected virtual bool TryRestoreAndValidateUnwindData( + UnwindData unwindData, + DateTime valueDate) + { + var position = FindRealtimeFloatPosition(unwindData); + if (!TryRestorePositionFromEod(position, valueDate)) + { + return false; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var effectiveQty = position.PosiQuantity; + var requestedQty = unwindData.CloseQty; + var fullClose = unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓 + || unwindData.ClosePercent >= 1m; + // CloseQty 是部分平仓请求的数量口径;全平请求忽略前端缓存的旧数量,统一取 EOD 有效数量。 + // 例如 10 送 10 后 EOD 为 2000 份/50,前端仍传 1000 份时,全平必须落成 2000 份, + // 否则会遗留 1000 份;现金派现后若 EOD 名义本金为 99000,平一半应按 49500 扣减。 + // 若交易级余额仍沿用旧值 100000,再扣有效平仓额 49500,就会错误留下 50500。 + if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty)) + { + throw new ServiceException( + $"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); + } + + var closeQty = fullClose ? effectiveQty : requestedQty; + var closeNotional = fullClose + ? position.PosiNotionalValue + : Math.Round( + closeQty * position.PosiGrossPrice * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + unwindData.PositionQty = effectiveQty; + unwindData.PosiNotionalValue = position.PosiNotionalValue; + unwindData.CloseQty = closeQty; + unwindData.CloseNotionalValue = closeNotional; + if (!fullClose) + { + unwindData.ClosePercent = unwindData.NotionalValue > 0m + ? closeNotional / unwindData.NotionalValue + : (effectiveQty == 0m ? 0m : closeQty / effectiveQty); + } + + if (floatEvent != null) + { + floatEvent.PosiGrossPrice = position.PosiGrossPrice; + floatEvent.PosiNetPrice = position.PosiNetPrice; + floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; + floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice; + floatEvent.Quantity = closeQty; + floatEvent.PositionQty = effectiveQty - closeQty; + floatEvent.ContractSize = position.ContractSize; + + // EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。 + // FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。 + UnwindNormalizer.RecalculateNormalizedUnwindAmounts(unwindData); + } + return true; + } + #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) @@ -202,6 +497,48 @@ namespace YLErp.Modules.SwapModule #endregion + /// + /// 根据指定日期刷新浮动腿基线(处理公司行为除权) + /// 用于前端修改平仓日期后重新获取除权后的持仓数量和价格 + /// + /// 交易ID + /// 平仓日期 + /// 返回浮动腿的最新基线数据 + public virtual (decimal PositionQty, decimal PosiNotionalValue, decimal PosiGrossPrice, decimal PosiNetPrice, bool IsRestored) RefreshFloatLegBaseline(int tradeId, DateTime valueDate) + { + var td = DbContext.trade.Find(tradeId); + if (td == null) + { + throw new ServiceException("未找到交易信息"); + } + + var positions = DbContext.swap_position.ActiveByTrade(tradeId); + var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + + if (position == null) + { + // 无浮动腿,返回trade表的原始值 + return ( + Convert.ToDecimal(td.TradeAmount), + Convert.ToDecimal(td.StockEqvNotional), + 0m, + 0m, + false + ); + } + + // 尝试恢复除权后的 EOD 基线 + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, valueDate); + + return ( + position.PosiQuantity, + position.PosiNotionalValue, + position.PosiGrossPrice, + position.PosiNetPrice, + restoredCorporateActionBaseline + ); + } + /// /// 平仓初始化 /// @@ -221,6 +558,11 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); + // Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, + // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); + // 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, + // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); swap_flow_event floatEvent = new swap_flow_event(); @@ -255,7 +597,11 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + // 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。 + // 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 + unwindData.PosiNotionalValue = restoredCorporateActionBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; // EQD-6977 罚息:平仓页「是否罚息」默认带出簿记值;以平仓时选择为准(可改),此处仅默认值 @@ -367,6 +713,10 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 + var restoredCorporateActionBaseline = TryRestorePositionFromEod(position, dealDate); + // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD + // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes); var hasProcess = HasTradeProcess(); @@ -403,7 +753,9 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + unwindData.PosiNotionalValue = restoredCorporateActionBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; @@ -905,13 +1257,13 @@ namespace YLErp.Modules.SwapModule isResetDay ? endDate : startDate, position.interest_rule); // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + + $"FIX GetFloatRate 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " + $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "无")}"); if (preEod.id != 0 && !isResetDay) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); position.FloatRate = positionClone.FloatRate = preEod.FloatRate; return preEod.FloatRate; } @@ -923,13 +1275,13 @@ namespace YLErp.Modules.SwapModule { var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return keptNoFetch; } if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } @@ -937,17 +1289,17 @@ namespace YLErp.Modules.SwapModule { if (calcLast) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); } // 算头不算尾(calcLast=false):endDate 当天不计息,其 FR007 利率不参与计息, // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return kept; } - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 计息窗口为空→利率不参与,返回0"); return 0m; } @@ -1376,15 +1728,15 @@ namespace YLErp.Modules.SwapModule if (fixing != 0m) { SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); return fixing; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); return fallback; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } @@ -1403,7 +1755,7 @@ namespace YLErp.Modules.SwapModule var calcDays = (endDate - startDate).Days; decimal currentFloat = initialFloat; SwapCalcTrace.Critical( - $"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + + $"FIX Segments 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + $"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " + $"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "无" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}"); for (int i = 0; i <= calcDays; i += interestPeriod) @@ -1422,7 +1774,7 @@ namespace YLErp.Modules.SwapModule else if (needFetch && isExcludedEnd) { SwapCalcTrace.Critical( - $"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); + $"FIX Segment 融资腿{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); } rates.Add((resetDate, spread + currentFloat)); } @@ -1627,6 +1979,18 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); + // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 + var restoredCorporateActionBaseline = TryRestoreAndValidateUnwindData(unwindData, unwindData.ValueDate); + // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), + // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 + if (restoredCorporateActionBaseline) + { + // 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。 + // 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500; + // 若仍从 trade 旧值 100000 扣减,会错误留下 50500。 + td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); + td.TradeAmount = Convert.ToDouble(unwindData.PositionQty); + } UnwindNormalizer.NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1688,6 +2052,8 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。 + // 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。 var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; @@ -1789,10 +2155,20 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); - // + 付息日>上日日终且小于等于平仓日期的分红数据 - var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); - decimal tax = um.ValueAddedTax ?? 0; + // 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/Fund 的公司行为 + // 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100。 + // 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。 + var useBondPriceScale = um == null + || !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType); + // + 付息日>上日日终且小于等于平仓日期的分红数据 + var dividendIn = servie.CalcPayment( + payments, + unwindQty, + shortRatio, + directionRatio, + useBondPriceScale); + decimal tax = um?.ValueAddedTax ?? 0; dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); @@ -1875,6 +2251,9 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); + // 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照, + // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 + // 不会在这里回写,属于审批链路的残余风险。 ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1911,6 +2290,9 @@ namespace YLErp.Modules.SwapModule { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } + // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。 + // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; + // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); UnwindNormalizer.NormalizeEventUnwindDate(swapEvent.unwindData); UnwindNormalizer.NormalizeNotionalValues(swapEvent.unwindData); @@ -2005,6 +2387,8 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } UnwindNormalizer.NormalizeEventUnwindDate(unwindData); + // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。 + // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(unwindData, td); diff --git a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs index c8786ab1..0517f9c3 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEndConfirmService.cs @@ -175,7 +175,15 @@ namespace YLErp.Modules.SwapModule var tradeContract = DbContext.trade_contract_r.Where(x => x.IsValid && x.TradeId == tradeId && x.Type == ContractTypeEnum.Trade).FirstOrDefault(); if (tradeContract == null) { - return ""; + // 定位要点:此处历史上静默 return "",前端当"已发送"但实际未发任何邮件。 + // 打出 warn 并把失败原因透传给前端,便于区分"没生成过交易确认书"与"被重生成作废(IsValid=false)"。 + var rows = DbContext.trade_contract_r.Where(x => x.TradeId == tradeId).Select(x => new { x.Type, x.IsValid, x.ContractCode }).ToList(); + var detail = rows.Any() + ? string.Join(";", rows.Select(r => $"Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}")) + : "trade_contract_r 无任何行"; + LogFactory.GetLogger("SwapEndConfirm").Error( + $"发送交易确认书邮件中止: tradeId={tradeId} 无有效交易确认书(需 IsValid=true 且 Type={ContractTypeEnum.Trade}); 库内实际行: {detail}"); + return "发送失败:未找到有效交易确认书,请先生成后再发送"; } tradeContract.send_email_result = "发送中"; DbContext.SaveChanges(); @@ -248,5 +256,47 @@ namespace YLErp.Modules.SwapModule } return "未配置邮件接口地址"; } + + /// + /// EQD-5320 批量发送结算确认书邮件:服务端代理 bond-oms /swap/email/settle/batchSend。 + /// 前端原直连 /trs_hub_api 反向代理(依赖 nginx 配置,未配的环境 404)——统一改走本代理, + /// 与债券计算器/SendEmail 同一条 BondOmsInterface_BaseUrl 出口,不再依赖前端网关。 + /// 返回 空串=成功;非空=失败原因(透传 bond-oms message)。 + /// + public string BatchSendSettleEmail(List swapFlowEventIds) + { + var baseUrl = Environment.GetEnvironmentVariable("BondOmsInterface_BaseUrl"); + if (string.IsNullOrEmpty(baseUrl)) + { + return "未配置邮件接口地址(BondOmsInterface_BaseUrl)"; + } + const string url = "/swap/email/settle/batchSend"; + var logger = LogFactory.GetLogger("SwapEndConfirm"); + var idsDesc = string.Join(",", swapFlowEventIds); + try + { + var result = new HttpHelper(baseUrl, null) + .PostRequestNoAuth(url, + new OmsSettleBatchSendReq { swapFlowEventIds = swapFlowEventIds }) + .Result; + logger.Info($"批量发送结算确认书: url={baseUrl}{url} ids=[{idsDesc}] → success={result?.success} message={result?.message}"); + if (result == null) + { + return "邮件服务无响应"; + } + return result.success ? "" : (result.message ?? "发送失败"); + } + catch (Exception ex) + { + logger.Error($"批量发送结算确认书异常: url={baseUrl}{url} ids=[{idsDesc}]", ex); + return "请求邮件服务异常:" + ex.GetBaseException().Message; + } + } + + /// bond-oms SettleEmailSendParam 契约(字段名须与 Java 端一致,Jackson 按 swapFlowEventIds 绑定) + private class OmsSettleBatchSendReq + { + public List swapFlowEventIds { get; set; } + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 714ba36d..fc73a583 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -14,6 +14,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.SwapModule.ReturnLegs; +using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -33,6 +34,118 @@ namespace YLErp.Modules.SwapModule } + /// + /// 在一组日终快照中选择严格早于指定日期的最近日。 + /// 回退到除权日 D 时必须得到 D 之前的基线;若使用 D 自身,除权后的 2000/50 + /// 会被当成除权前状态,重收盘时就可能再次套用 10 送 10。严格使用 < valueDate + /// 也覆盖周末、节假日:周一没有周日 EOD 时,直接选择上一个实际有快照的交易日。 + /// + public static DateTime? SelectLatestEodDateBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + if (eodPositions == null) + { + return null; + } + + var date = valueDate.Date; + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date < date) + .Select(x => (DateTime?)x.ValueDate.Date) + .OrderByDescending(x => x.Value) + .FirstOrDefault(); + } + + /// + /// 返回指定交易在 valueDate 之前最近实际 EOD 日的全部有效明细。 + /// 这是回退和纯单元测试共用的选择规则;调用方不得退化为 AddDays(-1),因为自然日 + /// 不等于交易日。若没有快照返回空集合,表示只能保留当前实时持仓,不能伪造基线。 + /// + public static List SelectLatestEodPositionsBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + var latestDate = SelectLatestEodDateBefore(eodPositions, valueDate); + if (!latestDate.HasValue) + { + return new List(); + } + + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date == latestDate.Value.Date) + .ToList(); + } + + /// + /// 从已经确认的 Stock/Fund EOD 快照恢复实时浮动腿的有效基线。 + /// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算 + /// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为 + /// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。 + /// 非 Fund、空快照或标的腿不满足收取方向时返回 false,保持原有逻辑。 + /// + public static bool RestoreFundPositionFromEod( + swap_position realtimePosition, + eod_swap_position eodPosition) + { + if (realtimePosition == null + || eodPosition == null + || realtimePosition.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(realtimePosition.UnderlyingInstrumentType) + || !IsTrsCorporateActionInstrument(eodPosition.UnderlyingInstrumentType)) + { + return false; + } + + realtimePosition.PosiQuantity = eodPosition.PosiQuantity; + realtimePosition.PosiGrossPrice = eodPosition.PosiGrossPrice; + realtimePosition.PosiNetPrice = eodPosition.PosiNetPrice; + realtimePosition.PosiNetFeePrice = eodPosition.PosiNetFeePrice; + realtimePosition.PosiNetNoFeePrice = eodPosition.PosiNetNoFeePrice; + realtimePosition.PosiNotionalValue = Math.Round( + eodPosition.PosiNotionalValue, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + realtimePosition.PosiTradingFeePending = eodPosition.PosiFeePending; + realtimePosition.PosiDividendIncome = eodPosition.PosiDividendSum; + return true; + } + + /// + /// 查询 valueDate 之前最近一份有效 Stock/Fund EOD 快照,作为盘中操作的日初基线。 + /// 必须严格使用 < valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据, + /// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50,D+1 盘中读取 D; + /// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。 + /// 调用方还需检查该 EOD 之后是否已有完成流水,避免覆盖当日部分平仓结果。 + /// + public virtual eod_swap_position GetLatestValidEodPosition( + int swapTradeId, + long positionId, + DateTime valueDate) + { + return DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId + && x.PositionId == positionId + && !x.Invalid + && x.ValueDate < valueDate.Date) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .FirstOrDefault(); + } + + /// + /// 回退/收益互换等路径需要的最近实际 EOD 快照集合;严格早于 valueDate,且过滤作废行。 + /// + public virtual List GetLatestEodPositionsBefore( + int swapTradeId, + DateTime valueDate) + { + var candidates = DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid && x.ValueDate < valueDate.Date) + .ToList(); + return SelectLatestEodPositionsBefore(candidates, valueDate); + } + private int GetStorageDeliveryPriceRound(string underlyingInstrumentType, string underlyingCode) { if (ConsGlobal.InstrumentType.IsBond(underlyingInstrumentType)) @@ -227,10 +340,27 @@ namespace YLErp.Modules.SwapModule return DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); } - /// 计算债券付息(生产: BondPaymentService;测试: 返回固定值) + /// + /// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。 + /// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为 + /// 分红行按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。 + /// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍。 + /// protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { - return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + var underlying = GetUnderlyingData(underlyingCode); + // 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于 + // 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。 + var useBondPriceScale = underlying == null + || !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType); + return new BondPaymentService(UserInfo).CalcPayment( + underlyingCode, + fromDate, + toDate, + qty, + shortRatio, + directionRatio, + useBondPriceScale); } // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- @@ -286,6 +416,63 @@ namespace YLErp.Modules.SwapModule .ToList(); } + /// 查询登记日或真实生效日命中的有效公司行为。 + protected virtual List FindCorporateActionInfos(DateTime settleDate) + { + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && ((x.ExDividendDate.HasValue && x.ExDividendDate.Value == settleDate.Date) + || (x.EffectiveDate.HasValue && x.EffectiveDate.Value == settleDate.Date))) + .ToList(); + } + + /// 查询交易已有公司行为事件,用于登记日/生效日幂等匹配。 + protected virtual List FindCorporateActionEvents(int swapTradeId) + { + return DbContext.swap_event + .Where(x => x.SwapTradeId == swapTradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && !x.Invalid) + .ToList(); + } + + /// 更新已存在的公司行为事件;默认只标记实体,统一由收盘事务保存。 + protected virtual void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + UpdateDbOption(swapEvent); + } + + /// + /// 获取公司行为公式使用的收盘价。 + /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 + /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日 + /// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + public static bool IsCorporateActionInstrument(string instrumentType) + { + // TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路, + // 这里不能用“非空标的类型”放宽,否则会把期权、期货等未验证品种一并启用。 + return string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase) + || string.Equals(instrumentType, ConsGlobal.InstrumentType.Stock, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTrsCorporateActionInstrument(string instrumentType) + => IsCorporateActionInstrument(instrumentType); + #endregion /// @@ -332,6 +519,42 @@ namespace YLErp.Modules.SwapModule var tradeExtendList = FindTradeExtends(tradeIds); var eodSwapList = FindEodSwapsByDate(preSettleDate); var completedFlowEvents = FindCompletedFlowEvents(tradeIds); + // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, + // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 + var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List(); + var exDividendInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date == settleDate.Date) + .ToList(); + var registrationInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.ExDividendDate.HasValue + && x.ExDividendDate.Value.Date == settleDate.Date) + .ToList(); + var duplicateDividend = exDividendInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateDividend != null) + { + throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); + } + // 公司行为去重 - 拦截 + var duplicateRegistration = registrationInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateRegistration != null) + { + // 登记日现金权益不能依赖数据库返回顺序取 First;同一标的同一登记日 + // 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。 + throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录"); + } + var exDividendByCode = exDividendInfos.ToDictionary( + x => x.UnderlyingCode, + x => x, + StringComparer.OrdinalIgnoreCase); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { @@ -371,8 +594,54 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 - //处理浮动腿 - var curEodPosis = DealFloatPositions(posiList, realPosiList, eodPositions, todyEodPositions, settleDate, td, preSettleDate, flowEvents); + // 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存 + // 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为 + // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 + // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 + // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + + // 重置基线 + var openingEodPositions = PrepareFundOpeningEodPositions( + eodPositions, + exDividendByCode, + settleDate); + + // 构建公司行为前eod持仓 + var corporateActionBeforePositions = BuildCorporateActionBeforePositions( + eodPositions, + posiList); + + // 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。 + // 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。 + var floatPositionsForCompose = eodPositions.Count == 0 + ? PrepareInitialCorporateActionPositions(posiList, exDividendByCode, settleDate) + : posiList; + + // 处理浮动腿归档 + var curEodPosis = DealFloatPositions( + floatPositionsForCompose, + realPosiList, + openingEodPositions, + todyEodPositions, + settleDate, + td, + preSettleDate, + flowEvents); + + // 现金分红不在登记日直接读取 ex_dividend_info 累加。 + // 同步任务会把 GiveCashAmount(每 10 份派现金额)写入 bond_payment_info,Copy/Update EOD 在 + // EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。 + // 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。 + RecordCorporateActionEvents( + td, + curEodPosis, + corporateActionBeforePositions, + registrationInfos, + exDividendInfos, + settleDate); + // 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要 + // 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。 + UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -402,6 +671,599 @@ namespace YLErp.Modules.SwapModule } } + + /// + /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。 + /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 + /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; + /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 + /// + protected List PrepareFundOpeningEodPositions( + IReadOnlyCollection previousEodPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + // 首日收盘或者当前非生效日 跳过 + if (previousEodPositions == null || previousEodPositions.Count == 0 + || exDividendByCode == null || exDividendByCode.Count == 0) + { + return previousEodPositions?.ToList() ?? new List(); + } + + // Clone 后只调整本次收盘的内存输入,不改 DbContext 跟踪的上一日实体; + // 否则重收盘或报表读取会把登记日的 Q/P 永久变成除权后 Q/P。 + var openingPositions = previousEodPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + // 应用公司行为 + ApplyCorporateActions( + openingPositions, + exDividendByCode, + settleDate); + return openingPositions; + } + + /// + /// 对 TRS Stock/Fund 浮动腿应用一条已按 EffectiveDate 筛选的份额/价格公司行为。 + /// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过 + /// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。 + /// 该步骤只改 EOD 持仓的份额/价格基线,不生成现金分红流水;现金模式下现金分红 + /// 不下调期初价格,而是由同步任务写入 bond_payment_info,后续付息链路单独计入。 + /// + /// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50; + /// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50, + /// 不能直接在当日结果上再次计算成 4000/25。 + /// + /// + /// 有流水时不在这里强行恢复前一日数量,因为 DealFloatPositions 已把当日开平仓滚动到当前结果; + /// 盘中平仓不会再次套公式,而是读取严格早于 valueDate 的最近有效 EOD,必要时按当日 + /// EffectiveDate 再生成开盘基线。 + /// + /// + protected void ApplyCorporateActions( + IEnumerable positions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + if (exDividendByCode.Count == 0) + { + return; + } + + // TODO: 现金分红税率接入后,仅价格调整模式需要读取税率;TRS 现金模式下不参与除权系数。 + // var dividendTaxRate = GetDividendTaxRate(); + var dividendTaxRate = 0m; + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo) + || !dividendInfo.EffectiveDate.HasValue + || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + { + continue; + } + + // 获取除权参考价 + var corporateActionClosePrice = GetFundCorporateActionClosePrice( + dividendInfo, + position.UnderlyingPrice); + if (corporateActionClosePrice <= 0) + { + throw new InvalidOperationException( + $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + } + + // Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”, + // 因此期初价格和持仓数量都使用同一个系数:P' = P / M,Q' = Q * M。 + // 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量; + // 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。 + var originalQuantity = position.PosiQuantity; + // 计算公司行为发生后的 Q/P + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, + dividendInfo, + corporateActionClosePrice, + dividendTaxRate, + GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode)); + position.PosiQuantity = adjusted.Quantity; + position.TdChangedQty = position.PosiQuantity - originalQuantity; + + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; + + var shortRatio = DirectionRatio.LongShort(position.PositionType); + var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + position.UnderlyingMarketValue = MtmCalc.MarketValue( + position.UnderlyingPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio); + position.PosiMtmPnL = EodPnlCalculator.RoundMoney(MtmCalc.UnrealizedPnl( + position.UnderlyingPrice, + position.PosiGrossPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio, + directionRatio)); + position.PosiProfitSum = EodPnlCalculator.RoundMoney(MtmCalc.ReturnLegProfitSum( + position.PosiMtmPnL, + position.PosiDividendSum, + position.PosiFeePending)); + position.SwapPositionValue = EodPnlCalculator.RoundMoney(PositionValueCalc.Calc( + position.InterestProfitSum, + position.PosiProfitSum)); + position.PosiStatus = position.PosiQuantity == 0 ? 1 : 0; + } + } + + /// + /// 构造审计事件的调整前快照。优先克隆上一 EOD,保证后续调整不会污染历史实体; + /// 交易首日没有 EOD 时才从初始持仓复制,并把累计分红/已实现字段初始化为 0。 + /// + private static List BuildCorporateActionBeforePositions( + IReadOnlyCollection previousPositions, + IReadOnlyCollection initialPositions) + { + if (previousPositions != null && previousPositions.Count > 0) + { + return previousPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + } + + return (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => new eod_swap_position + { + PositionId = x.PositionId, + UnderlyingCode = x.UnderlyingCode, + UnderlyingInstrumentType = x.UnderlyingInstrumentType, + PosiDirection = x.PosiDirection, + PositionType = x.PositionType, + ContractSize = x.ContractSize, + CountRatio = x.CountRatio, + PosiQuantity = x.PosiQuantity, + PosiGrossPrice = x.PosiGrossPrice, + PosiNetPrice = x.PosiNetPrice, + PosiNetFeePrice = x.PosiNetFeePrice, + PosiNetNoFeePrice = x.PosiNetNoFeePrice, + PosiNotionalValue = x.PosiNotionalValue, + PosiTradingFee = x.PosiTradingFee, + PosiFeePending = x.PosiTradingFeePending, + PosiDividendSum = 0m, + RealizedDividend = 0m, + PosiStatus = x.PosiQuantity == 0m ? 1 : 0 + }) + .ToList(); + } + + /// + /// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线。 + /// 不直接修改初始持仓实体,避免重收盘或后续流程再次读取时重复套用系数。 + /// + private List PrepareInitialCorporateActionPositions( + IReadOnlyCollection initialPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + var positions = (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + if (positions.Count == 0 || exDividendByCode == null || exDividendByCode.Count == 0) + { + return positions; + } + + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var info)) + { + continue; + } + + var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice); + ApplyCorporateActionToPosition( + position, + info, + closePrice, + 0m); + } + + return positions; + } + + /// + /// 同步公司行为后的实时浮动腿。 + /// 登记日只更新下一交易日 BOD 使用的实时 Q/P,不改当日已落库的 EOD; + /// 生效日则把已调整的 EOD 复制到实时腿。每次都先从当日 EOD 恢复,保证重跑幂等。 + /// + private void UpdateRealtimeCorporateActionPositions( + trade td, + IReadOnlyCollection currentEodPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentEodPositions == null || currentEodPositions.Count == 0) + { + return; + } + + // 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的 + // 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。 + var pendingInfos = (registrationInfos ?? Array.Empty()) + .Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date) + .ToList(); + var appliedInfos = effectiveInfos ?? Array.Empty(); + + foreach (var eod in currentEodPositions.Where(x => x != null + && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType) + && !string.IsNullOrWhiteSpace(x.UnderlyingCode))) + { + var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id + && !x.Invalid + && !x.IsInitial + && x.PositionId == eod.PositionId); + if (realtime == null) + { + continue; + } + + var pending = pendingInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (pending != null) + { + // 必须从登记日 EOD 基线生成下一交易日 BOD,而不是在旧实时腿上继续套系数; + // 这样 100000/100 只会变成一次 200000/50,并且不会把初始腿改掉。 + // 先将实时腿恢复为登记日 EOD 的旧基线,再只对实时腿应用一次公司行为。 + // EOD 仍保持除权前快照;因此 7/10 EOD=100000/100,而 7/13 BOD=200000/50。 + var baseline = eod.Clone(); + UpdateSwapPositionWithRealTime(baseline); + var closePrice = GetFundCorporateActionClosePrice(pending, baseline.PosiGrossPrice); + ApplyCorporateActionToPosition(realtime, pending, closePrice, 0m); + continue; + } + + var applied = appliedInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (applied != null) + { + // 生效日 EOD 已经完成 Q/P 调整,实时腿直接同步最终快照,不再二次套系数。 + UpdateSwapPositionWithRealTime(eod.Clone()); + } + } + } + + /// + /// 写入公司行为生命周期审计事件。 + /// 登记日:保存调整前快照并标记 Applied=false; + /// 真实除权日:使用上一 EOD 与当前 EOD 补齐调整后快照并标记 Applied=true。 + /// 事件数据只追加/补齐,不删除已生效记录, + /// 便于交易回退后通过 BackId 关联新的回退记录。 + /// + protected virtual void RecordCorporateActionEvents( + trade td, + IReadOnlyCollection currentPositions, + IReadOnlyCollection previousPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentPositions == null) + { + return; + } + + var infos = (registrationInfos ?? Array.Empty()) + .Concat(effectiveInfos ?? Array.Empty()) + .Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode)) + .GroupBy(x => new + { + x.id, + x.UnderlyingCode, + ExDividendDate = x.ExDividendDate?.Date, + EffectiveDate = x.EffectiveDate?.Date + }) + .Select(x => x.First()) + .ToList(); + if (infos.Count == 0) + { + return; + } + + var existingEvents = FindCorporateActionEvents(td.id); + foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType))) + { + var info = infos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, + current.UnderlyingCode, + StringComparison.OrdinalIgnoreCase)); + if (info == null) + { + continue; + } + + // 公司行为事件只使用“公司行为记录主键 + PositionId”作为幂等键。 + var matchingEvents = existingEvents + .Select(x => new { Event = x, Data = DeserializeCorporateActionEventData(x.EventData) }) + .Where(x => x.Data != null + && info.id > 0 + && x.Data.ExDividendInfoId == info.id + && x.Data.PositionId == current.PositionId) + .ToList(); + var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied) + ?? matchingEvents.FirstOrDefault(); + var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId); + // 登记日 false 除权日 true + var isEffective = info.EffectiveDate.HasValue + && info.EffectiveDate.Value.Date <= settleDate.Date + && effectiveInfos != null + && effectiveInfos.Any(x => x.id == info.id); + + // 如果没有匹配到事件或事件未生效,则创建新事件。 + if (eventData == null || (!isEffective && eventData.Data.Applied)) + { + // 创建新事件 + var pending = BuildCorporateActionEventData( + info, + previous ?? current, + isEffective ? current : null, + applied: isEffective); + // 生命周期事件的发生日固定为登记日,EffectiveDate 只表示 Q/P 基线切换日。 + // 这样回退后重收盘仍能按原登记日排序和追溯,不会把同一事件拆成两条历史。 + var eventDate = info.ExDividendDate?.Date + ?? info.EffectiveDate?.Date + ?? settleDate.Date; + var created = AddSwapEvent( + eventDate, + td.id, + (int)SwapEventTypeEnum.公司行为, + JsonConvert.SerializeObject(pending), + 0, + false, + BuildCorporateActionReason(pending)); + if (created == null) + { + created = new swap_event(); + } + // 测试接缝和历史实现可能返回只带 id 的实体;统一补齐字段, + // 确保同一收盘事务内的生效步骤能找到刚创建的事件。 + created.EventType = (int)SwapEventTypeEnum.公司行为; + created.SwapTradeId = td.id; + created.ValueDate = eventDate; + created.EventData = JsonConvert.SerializeObject(pending); + created.EventReason = BuildCorporateActionReason(pending); + existingEvents.Add(created); + continue; + } + + // 如果不是生效日或事件已生效,则跳过。 + if (!isEffective || eventData.Data.Applied) + { + continue; + } + + // 生效日只补齐同一事件的 Before/After 快照,不重新套系数:Before* 来自 + // 调整前 EOD,After* 来自生效日当前 EOD,current 已由开盘基线处理完成。 + eventData.Data.BeforeNotional = previous?.PosiNotionalValue ?? eventData.Data.BeforeNotional; + eventData.Data.BeforePrice = previous?.PosiGrossPrice ?? eventData.Data.BeforePrice; + eventData.Data.BeforeQuantity = previous?.PosiQuantity ?? eventData.Data.BeforeQuantity; + eventData.Data.BeforePendingDividend = previous?.PosiDividendSum ?? eventData.Data.BeforePendingDividend; + eventData.Data.AfterNotional = current.PosiNotionalValue; + eventData.Data.AfterPrice = current.PosiGrossPrice; + eventData.Data.AfterQuantity = current.PosiQuantity; + eventData.Data.AfterPendingDividend = current.PosiDividendSum; + eventData.Data.CashFlowChange = current.RealizedDividend - (previous?.RealizedDividend ?? current.RealizedDividend); + eventData.Data.Applied = true; + eventData.Event.EventData = JsonConvert.SerializeObject(eventData.Data); + eventData.Event.EventReason = BuildCorporateActionReason(eventData.Data); + UpdateCorporateActionEventRecord(eventData.Event); + } + } + + public static CorporateActionEventData BuildCorporateActionEventData( + ex_dividend_info info, + eod_swap_position previous, + eod_swap_position current, + bool applied) + { + return new CorporateActionEventData + { + ExDividendInfoId = info.id, + PositionId = (current ?? previous).PositionId, + UnderlyingCode = (current ?? previous).UnderlyingCode, + ExDividendDate = info.ExDividendDate, + EffectiveDate = info.EffectiveDate, + GiveCashAmount = info.GiveCashAmount, + GiveShareAmount = info.GiveShareAmount, + Split = info.Split, + RationedSharesAmount = info.RationedSharesAmount, + RationedSharesPrice = info.RationedSharesPrice, + BeforeNotional = previous?.PosiNotionalValue ?? 0m, + BeforePrice = previous?.PosiGrossPrice ?? 0m, + BeforeQuantity = previous?.PosiQuantity ?? 0m, + AfterNotional = applied ? current?.PosiNotionalValue ?? 0m : 0m, + AfterPrice = applied ? current?.PosiGrossPrice ?? 0m : 0m, + AfterQuantity = applied ? current?.PosiQuantity ?? 0m : 0m, + BeforePendingDividend = previous?.PosiDividendSum ?? 0m, + AfterPendingDividend = applied ? current?.PosiDividendSum ?? 0m : 0m, + CashFlowChange = applied ? (current?.RealizedDividend ?? 0m) - (previous?.RealizedDividend ?? 0m) : 0m, + Applied = applied, + }; + } + + public static bool ShouldCreateCorporateActionEvent( + IEnumerable events, + ex_dividend_info info, + long positionId) + { + if (info == null) + { + return false; + } + + // 幂等键与收盘事件匹配保持一致,只认 ExDividendInfoId + PositionId。 + // 无法反序列化或缺少 ExDividendInfoId 的存量事件均不参与匹配。 + return !(events ?? Enumerable.Empty()).Any(x => + { + if (!SwapEventService.TryDeserializeCorporateActionEventData(x, out var data)) + { + return false; + } + return info.id > 0 + && data.ExDividendInfoId == info.id + && data.PositionId == positionId; + }); + } + + private static CorporateActionEventData DeserializeCorporateActionEventData(string eventData) + { + if (string.IsNullOrWhiteSpace(eventData)) + { + return null; + } + try + { + return JsonConvert.DeserializeObject(eventData); + } + catch (JsonException) + { + return null; + } + } + + private static string BuildCorporateActionReason(CorporateActionEventData data) + { + return SwapEventService.BuildCorporateActionEventReason(data); + } + + /// 公司行为调整后的持仓 Q/P 结果,供 EOD、实时腿和盘中平仓共用。 + private readonly struct CorporateActionValues + { + public CorporateActionValues(decimal quantity, decimal grossPrice, decimal netPrice, decimal? netFeePrice, decimal? netNoFeePrice) + { + Quantity = quantity; + GrossPrice = grossPrice; + NetPrice = netPrice; + NetFeePrice = netFeePrice; + NetNoFeePrice = netNoFeePrice; + } + + public decimal Quantity { get; } + public decimal GrossPrice { get; } + public decimal NetPrice { get; } + public decimal? NetFeePrice { get; } + public decimal? NetNoFeePrice { get; } + } + + /// + /// 统一计算公司行为后的 Q/P。EOD、实时腿和盘中平仓只负责提供基线, + /// 不再各自复制数量、毛价和净价的调整公式。 + /// + private static CorporateActionValues CalculateCorporateActionValues( + decimal quantity, + decimal grossPrice, + decimal netPrice, + decimal? netFeePrice, + decimal? netNoFeePrice, + ex_dividend_info dividendInfo, + decimal closePrice, + decimal dividendTaxRate, + int grossPriceRound) + { + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + closePrice, + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) + { + throw new InvalidOperationException( + $"标的【{dividendInfo?.UnderlyingCode}】计算得到无效除权系数"); + } + + var adjustedQuantity = Math.Round(quantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); + var adjustedGrossPrice = Math.Round(grossPrice / factors.PriceRatio, grossPriceRound, MidpointRounding.AwayFromZero); + var adjustedNetPrice = Math.Round(netPrice / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + var adjustedNetFeePrice = netFeePrice.HasValue + ? Math.Round(netFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + var adjustedNetNoFeePrice = netNoFeePrice.HasValue + ? Math.Round(netNoFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + return new CorporateActionValues( + adjustedQuantity, + adjustedGrossPrice, + adjustedNetPrice, + adjustedNetFeePrice, + adjustedNetNoFeePrice); + } + + /// + /// 将一条真实生效日公司行为应用到盘中实时 TRS Stock/Fund 浮动腿。 + /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 + /// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。 + /// 例:8 月 14 日 EOD 为 1000/100,8 月 17 日生效的 10 送 10 会得到 2000/50。 + /// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段, + /// 不改变 Stock/Fund 的期初价格。 + /// + public static bool ApplyCorporateActionToPosition( + swap_position position, + ex_dividend_info dividendInfo, + decimal corporateActionClosePrice, + decimal dividendTaxRate) + { + if (position == null + || dividendInfo == null + || position.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) + || corporateActionClosePrice <= 0) + { + return false; + } + + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, + dividendInfo, + corporateActionClosePrice, + dividendTaxRate, + ConsGlobal.SwapDeliveryPriceRound); + position.PosiQuantity = adjusted.Quantity; + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + return true; + } + /// /// 框架合约汇总 /// @@ -660,6 +1522,11 @@ namespace YLErp.Modules.SwapModule var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0); if (!hasDividend) return; + // 公司行为现金分红与债券付息共用既有待实现/支付链路:公司行为步骤只把金额 + // 累加到 PosiDividendSum,这里仍按交易约定的 DividendPayDate 生成支付流水。 + // 公司行为不会调整 Stock/Fund 的期初价格;因此不能再把现金分红从 PosiMtmPnL + // 中剥离或当作已实现收益提前写入。 + var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; if (dividendPayDateOffset <= 0) return; @@ -1380,7 +2247,7 @@ namespace YLErp.Modules.SwapModule { var ongoingFixing = ResolveOngoingResetFixing(position, valueDate); SwapCalcTrace.Critical( - $"FIX EodCloseRefix p{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); + $"FIX EodCloseRefix 融资腿{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); newEodPayPosition.FloatRate = ongoingFixing; } //利息端估值用信息 diff --git a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs index a6bad2cc..f6a8e720 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventEmailService.cs @@ -164,6 +164,12 @@ namespace YLErp.Modules.SwapModule && f.UnwindDate == valueDate && f.PayDirection > 0 && eventTypes.Contains(f.EventType) && f.DataState == (int)SwapFlowDateStateEnum.完成 select f; var flowEventList = flowQuery.ToList(); + // 定位要点:发送范围按【客户+日期】整组扩张——用户勾选 A 事件,同客户同日的 B/C 事件会被一并拉入, + // 后续校验报错常是被拉入的事件缺确认书(用户以为"刚生成了还报错")。此日志把勾选与扩张结果对齐打出来。 + LogFactory.GetLogger("SwapEventEmail").Info( + $"资金提示邮件-发送范围: 用户勾选事件[{string.Join(",", EventEmailEmails.Select(s => s.event_id))}] " + + $"客户[{string.Join(",", clientIds.Distinct())}] 日期[{valueDate:yyyy-MM-dd}] " + + $"扩张后实际处理事件[{string.Join(",", flowEventList.Select(f => $"{f.id}:{f.SwapTradeNo}:" + (f.EventType == (int)SwapFlowEventTypeEnum.开仓 ? "开仓" : f.EventType == (int)SwapFlowEventTypeEnum.平仓 ? "平仓" : "T" + f.EventType)))}]"); var docs = new List(); var openFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); var closeFlowEventList = flowEventList.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓).ToList(); @@ -216,6 +222,22 @@ namespace YLErp.Modules.SwapModule } if (tradeNumbers.Any()) { + // 定位要点:报错只带交易编号不够定位。补打每笔缺失交易的 trade_contract_r 实际行状态 + //(无行=从未生成 / IsValid=false=被重生成作废 / document 行缺失=确认书文档被清理),命中哪种一眼可辨。 + var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList(); + var missRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId)).ToList(); + var missCodes = missRows.Select(r => r.ContractCode).Distinct().ToList(); + var docCodes = DbContext.trade_contract_document.Where(d => missCodes.Contains(d.Code)).Select(d => d.Code).Distinct().ToList(); + var detail = string.Join(";", missTradeIds.Select(tid => + { + var tradeNo = flowEvents.First(f => f.SwapTradeId == tid).SwapTradeNo; + var rows = missRows.Where(x => x.TradeId == tid).ToList(); + if (!rows.Any()) return $"{tradeNo}: trade_contract_r 无任何行(从未生成交易确认书)"; + return $"{tradeNo}: " + string.Join(",", rows.Select(r => + $"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode},doc存在={(docCodes.Contains(r.ContractCode) ? "是" : "否")}]")); + })); + LogFactory.GetLogger("SwapEventEmail").Error( + $"交易确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效交易确认书; 库内明细: {detail}"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的交易确认书附件,请检查或生成后再发送邮件"); } return list; @@ -265,6 +287,24 @@ namespace YLErp.Modules.SwapModule } if (tradeNumbers.Any()) { + // 定位要点:结算确认书按 SwapFlowEventId 精确匹配。补打每笔缺失平仓事件的合同行状态—— + // 常见原因是生成时的扩张查询覆盖了本事件但用户实际生成的是另一批,或重生成后 IsValid 被作废。 + var missEventIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.id).Distinct().ToList(); + var missRows = DbContext.trade_contract_r.Where(x => missEventIds.Contains(x.SwapFlowEventId ?? 0)).ToList(); + var missTradeIds = flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).Select(s => s.SwapTradeId).Distinct().ToList(); + var tradeTypeRows = DbContext.trade_contract_r.Where(x => missTradeIds.Contains(x.TradeId) && x.Type == ContractTypeEnum.Clearing).ToList(); + var detail = string.Join(";", flowEvents.Where(w => tradeNumbers.Contains(w.SwapTradeNo)).GroupBy(g => g.SwapTradeNo).Select(g => + { + var rows = missRows.Where(x => x.SwapFlowEventId == g.First().id).ToList(); + var byTrade = tradeTypeRows.Where(x => x.TradeId == g.First().SwapTradeId).ToList(); + return $"{g.Key}(事件{g.First().id}): " + (rows.Any() + ? string.Join(",", rows.Select(r => $"[Type={r.Type},IsValid={r.IsValid},Code={r.ContractCode}]")) + : $"按事件无行; 按交易的Clearing行=" + (byTrade.Any() + ? string.Join(",", byTrade.Select(r => $"[SwapFlowEventId={r.SwapFlowEventId},IsValid={r.IsValid},Code={r.ContractCode}]")) + : "无")); + })); + LogFactory.GetLogger("SwapEventEmail").Error( + $"结算确认书校验失败: 交易编号[{string.Join(",", tradeNumbers.Distinct())}]找不到有效结算确认书(SwapFlowEventId匹配); 库内明细: {detail}"); throw new Exception($"交易编号为{string.Join(",", tradeNumbers.Distinct())}找不到有效的结算确认书附件,请检查或生成后再发送邮件"); } return list; @@ -764,7 +804,11 @@ namespace YLErp.Modules.SwapModule if (System.IO.File.Exists(fName)) { return fName; } else - { return null; } + { + // 定位要点:物理文件缺失历史上静默返回 null(附件列表混入 null,发送结果不可预期),补 warn 便于发现"库里行在、盘上文件丢"。 + LogFactory.GetLogger("SwapEventEmail").Error($"邮件附件物理文件缺失: trade_contract_document.Paths={baseName} 映射后={fName} 不存在"); + return null; + } } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index 782b83e1..711bc06f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text; @@ -206,16 +207,89 @@ namespace YLErp.Modules.SwapModule return events; } /// - /// 获取交易操作历史 + /// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留, + /// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯。 /// /// 交易id /// public List GetOpreationHistorys(int tradeId) { - List list = DbContext.swap_event.Where(x => x.SwapTradeId == tradeId).OrderByDescending(o => o.id).ToList(); + List list = DbContext.swap_event + .Where(x => x.SwapTradeId == tradeId) + .OrderByDescending(o => o.id) + .ToList(); return list; } + /// + /// 将 swap_event.EventData 安全反序列化为公司行为快照。事件为空、EventData + /// 为空白或 JSON 格式不匹配时返回 false 并将 data 置 null,调用方据此保留旧格式记录。 + /// + public static bool TryDeserializeCorporateActionEventData( + swap_event swapEvent, + out CorporateActionEventData data) + { + data = null; + if (swapEvent == null || string.IsNullOrWhiteSpace(swapEvent.EventData)) + { + return false; + } + + try + { + data = JsonConvert.DeserializeObject(swapEvent.EventData); + return data != null; + } + catch (JsonException) + { + return false; + } + } + + /// + /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、 + /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。 + /// + public static string BuildCorporateActionEventReason(CorporateActionEventData data) + { + if (data == null) + { + return "公司行为快照为空"; + } + + // 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。 + string D(decimal value) => value.ToString(CultureInfo.InvariantCulture); + string Date(DateTime? value) => value.HasValue + ? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : ""; + + return string.Join("; ", new[] + { + $"公司行为[{data.UnderlyingCode}]", + $"ExDividendDate={Date(data.ExDividendDate)}", + $"EffectiveDate={Date(data.EffectiveDate)}", + $"ExDividendInfoId={data.ExDividendInfoId}", + $"PositionId={data.PositionId}", + $"GiveCashAmount={D(data.GiveCashAmount)}", + $"GiveShareAmount={D(data.GiveShareAmount)}", + $"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}", + $"RationedSharesAmount={D(data.RationedSharesAmount)}", + $"RationedSharesPrice={D(data.RationedSharesPrice)}", + "调整前", + $"BeforeNotional={D(data.BeforeNotional)}", + $"BeforePrice={D(data.BeforePrice)}", + $"BeforeQuantity={D(data.BeforeQuantity)}", + $"BeforePendingDividend={D(data.BeforePendingDividend)}", + "调整后", + $"AfterNotional={D(data.AfterNotional)}", + $"AfterPrice={D(data.AfterPrice)}", + $"AfterQuantity={D(data.AfterQuantity)}", + $"AfterPendingDividend={D(data.AfterPendingDividend)}", + $"CashFlowChange={D(data.CashFlowChange)}", + $"Applied={data.Applied}" + }); + } + public void DeleteEvent(int tradeId) { var events = DbContext.swap_event.Where(x => x.Invalid && x.SwapTradeId == tradeId).ToList(); diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index 8a3768fd..ce6d0e18 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -76,7 +76,12 @@ namespace YLErp.Modules.SwapModule /// public void UpdateSwapPositionWithRealTime(eod_swap_position eodPayPosition) { - var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId); + // 同一 PositionId 同时对应初始腿(id=PositionId)和实时腿(IsInitial=0)。 + // 日终/公司行为同步只能更新实时腿;若直接 FirstOrDefault,会随机命中初始腿, + // 造成 EOD 已是 200000/50 而交易详情仍保持 100000/100,或反向污染交易初始腿。 + var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId + && !x.IsInitial + && !x.Invalid); if (position != null) { position.PosiQuantity = eodPayPosition.PosiQuantity; @@ -96,7 +101,13 @@ namespace YLErp.Modules.SwapModule } else { + // 兼容实时腿尚未生成的首日/自动合成场景:只从初始腿克隆创建实时腿, + // 不能把初始腿当作可更新对象。 position = DbContext.swap_position.FirstOrDefault(x => x.id == eodPayPosition.PositionId); + if (position == null) + { + return; + } var posi = position.Clone(); posi.id = 0; posi.PositionId = position.id; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 17adb85f..279d56b4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1250,6 +1250,10 @@ namespace YLErp.Modules.SwapModule { return; } + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(tradeObj, out var errorMessage)) + { + throw new ServiceException(errorMessage); + } var eventTypes = new List() { (int)SwapEventTypeEnum.修改交易, (int)SwapEventTypeEnum.新增交易, (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; var lastEvent = DbContext.swap_event.Where(x => x.SwapTradeId == intid && eventTypes.Contains(x.EventType) && !x.Invalid).OrderByDescending(o => o.OptTime).FirstOrDefault(); if (tradeObj.TradeStatus == ConsTrade.平仓待复核 || tradeObj.TradeStatus == ConsTrade.互换待复核) @@ -1538,14 +1542,34 @@ namespace YLErp.Modules.SwapModule } else { + // 按日期回退时只恢复 valueDate 之前最近实际收盘的快照;当天及以后数据会在 + // InvalidTradeOptionDatasByDate 中清理。这样回退到除权日 D 会回到 D-1 的 + // 1000 份/100 基线并让重收盘重新应用公司行为;回退到 D+1 则保留 D 的 2000 份/50。 TradeBackByDate(td, valueDate, swapPositions); } if (swapEvent != null)//展期 { swapEventService.DeleteExtensionTime(swapEvent.id); } + var corporateActionEvents = DbContext.swap_event + .Where(x => !x.Invalid + && x.SwapTradeId == tradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && x.ValueDate >= valueDate) + .OrderByDescending(x => x.id) + .ToList(); InvalidTradeOptionDatasByDate(tradeId, valueDate, backToBegin); - swapEventService.AddSwapEventDate(valueDate, tradeId, (int)SwapEventTypeEnum.回退, string.Empty, 0, false, $"交易回退至{valueDate:yyyy年MM月dd日}"); + var rollbackEvent = swapEventService.AddSwapEventDate( + valueDate, + tradeId, + (int)SwapEventTypeEnum.回退, + string.Empty, + 0, + false, + $"交易回退至{valueDate:yyyy年MM月dd日}"); + // 公司行为原事件保持有效作为不可篡改审计;回退事件通过 BackId 指向本次 + // 回退影响的最新公司行为事件,后续重收盘会追加新的公司行为事件。 + rollbackEvent.BackId = corporateActionEvents.FirstOrDefault()?.id ?? 0; DbContext.SaveChanges(); if (del) { @@ -1673,8 +1697,14 @@ namespace YLErp.Modules.SwapModule { SwapEodPositionService eodPositionService = new SwapEodPositionService(this); SwapDealService swapDealService = new SwapDealService(this); - var preDay = valueDate.AddDays(-1); - var eodSwapPositionList = DbContext.eod_swap_position.Where(x => x.ValueDate == preDay && x.SwapTradeId == td.id).ToList(); + // 回退基线必须是 valueDate 之前最近一个实际 EOD,而不是 valueDate-1 自然日。 + // 例如周一/节假日后的 valueDate 没有周日 EOD 时,AddDays(-1) 会得到空集合, + // 实时腿仍保留除权后的数量/价格。回退到除权日 D 选择 D 前基线并由下面的 + // InvalidTradeOptionDatasByDate 删除 D 及以后 EOD;回退到 D+1 则会选择 D, + // 保留 D 已生效的公司行为。ex_dividend_info 本身不删除,重收盘 D 会再应用一次。 + var eodSwapPositionList = eodPositionService.GetLatestEodPositionsBefore(td.id, valueDate); + // 没有 valueDate 之前的有效 EOD 时,eodSwapPositionList 为空;这是“没有可证明基线”的情况, + // 下面不会伪造数量/价格或重算公司行为,只保留当前实时持仓并继续清理回退日之后的数据。 var swapFlowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id && x.EventDate >= valueDate && x.DataState > (int)SwapFlowDateStateEnum.废弃).ToList(); var positions = swapPositions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList(); @@ -1700,6 +1730,8 @@ namespace YLErp.Modules.SwapModule td.TradeAmount = Convert.ToDouble(posi.PosiQuantity); } } + // eodPosi 为空时刻意不改 posi:回退只能使用已落库的历史快照,不能把缺失数据 + // 猜成 0 或交易初始值,否则会把未验证的 Fund 除权数量带入后续收盘。 } td.UnWindDate = null; td.UnWindNotional = null; @@ -1740,6 +1772,10 @@ namespace YLErp.Modules.SwapModule /// private void InvalidTradeOptionDatasByDate(int tradeId, DateTime valueDate, bool backToBegin) { + // 所有清理条件都采用闭区间起点 [valueDate, +∞):回退到除权日 D 要删除 D 当天 + // 已应用的 EOD/流水,随后重收盘 D 才会从 D-1 快照重新套一次系数;回退到 D+1 + // 不会删除 D,因而保留 D 已生效的 2000 份/50。valueDate 之前的快照始终保留, + // 作为 TradeBackByDate 的唯一可验证基线。 var swapEvents = DbContext.swap_event.Where(x => !x.Invalid && x.SwapTradeId == tradeId && x.ValueDate >= valueDate).ToList(); var swapEodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); var swapEods = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); @@ -1747,6 +1783,12 @@ namespace YLErp.Modules.SwapModule var firstConfirm = false; swapEvents.ForEach(x => { + // 公司行为事件是不可篡改审计日志。回退只追加回退事件,不把原始公司 + // 行为事件置无效;否则无法追溯交易曾经经历过的调整。 + if (x.EventType == (int)SwapEventTypeEnum.公司行为) + { + return; + } if (backToBegin && !firstConfirm && x.EventType == (int)SwapEventTypeEnum.确认交易) { firstConfirm = true; diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 4e9be366..e62f6c34 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -59,7 +59,8 @@ namespace YLErp.Modules.SystemModule parentNode = item.parentNode, approvalCondition = item.approvalCondition, conditionConfig = item.conditionConfig, - triggerCondition = item.triggerCondition + triggerCondition = item.triggerCondition, + isOaApproval = item.isOaApproval }).ToList(); DbContext.approvalprocess.AddRange(list); @@ -790,6 +791,8 @@ namespace YLErp.Modules.SystemModule /// 节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。 public string triggerCondition { get; set; } + + public bool isOaApproval { get; set; } } /// /// 审批流程修改节点 diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index ce9a9714..8b026f5d 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -20,6 +20,11 @@ namespace YLErp.Modules.TradeModule.DealModule } + public DividendService(YLBaseService baseService) : base(baseService) + { + + } + /// /// 场内交易除权除息 /// @@ -80,7 +85,9 @@ namespace YLErp.Modules.TradeModule.DealModule var dict = GetExDividendQuery(settleDate) .ToDictionary(K => K.UnderlyingId, V => V); var tradeIds = trades.Select(O => O.id); - var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); + var dividendRatioDict = new DbRecordChangesService(this) + .GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate) + .ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); foreach (var t in trades) { var bodTrade = new bod_trade(); @@ -102,7 +109,9 @@ namespace YLErp.Modules.TradeModule.DealModule { annualizeFactor = t.trade_snowball.AnnualizeFactor2; } - t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) : t.StockEqvNotionalReal; + t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 + ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) + : t.StockEqvNotionalReal; t.OriginalNotional = t.StockEqvNotionalReal / t.SpotPrice; t.TradeOriginalAmount = t.OriginalNotional / (t.CountRatio ?? 1); //不管是不是名义本金方式了结,都应该按照比例了结。--时嬴政 @@ -722,6 +731,7 @@ namespace YLErp.Modules.TradeModule.DealModule { return 0; } + // 价格 / 系数 var result = (decimal)price / decimalRatio; return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero); } @@ -736,14 +746,94 @@ namespace YLErp.Modules.TradeModule.DealModule return (double)GetRatioDecimal(info); } + internal readonly struct CorporateActionFactors + { + public CorporateActionFactors(decimal priceRatio) + { + PriceRatio = priceRatio; + } + + public decimal PriceRatio { get; } + } + + /// + /// 按 Excel 公式计算公司行为的除权系数。 + /// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数; + /// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格 + /// 和持仓数量,不再维护独立的旧数量系数。 + /// + /// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。 + /// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。 + /// + /// + internal static CorporateActionFactors CalculateCorporateActionFactors( + ex_dividend_info info, + decimal closePrice, + decimal dividendRate, + bool adjustCashDividendPrice = true) + { + // 价格调整模式除权参考价 = + // 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价 + // - ----------------------------------------------------- + // (10 + 送股数 + 配股数) * 拆股倍数 + // 场内链路默认继续把现金派息计入除权参考价; + // TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。 + var cashPriceAdjustment = adjustCashDividendPrice + ? info.GiveCashAmount * (1m - dividendRate) + : 0m; + // 拆股倍数 + var splitFactor = GetSplitFactor(info); + // 除权参考价(TRS) : + // 收盘价 * 10 + 配股数 * 配股价 + // ------------------------------ + // (10 + 送股数 + 配股数) * 拆股倍数 + var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment + + info.RationedSharesAmount * info.RationedSharesPrice) + / (10m + info.GiveShareAmount + info.RationedSharesAmount)) + / splitFactor; + // 除权系数 = 股权登记日收盘价 / 除权除息参考价 + var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice; + return new CorporateActionFactors(priceRatio); + } + + private static decimal GetSplitFactor(ex_dividend_info info) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ArgumentOutOfRangeException(nameof(info.Split), "拆/合股倍数必须大于 0"); + } + + // Split 为空表示未提供拆合股信息,按 1 兼容历史记录;例如 Split=0.1 时, + // 1000 份/100 元调整为 100 份/1000 元。0 或负数无法表达有效份额比例,直接拒绝。 + return info.Split ?? 1m; + } + + /// + /// 将系统配置中的百分数税率转换为公司行为公式使用的小数税率。 + /// 例如配置 13 表示 13%,返回 0.13;送股系数不使用该税率,只有现金分红的税后金额使用。 + /// + internal decimal GetDividendTaxRateDecimal() + { + return (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + } + + /** + * GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1); + * 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split; + * 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。 + */ private decimal GetRatioDecimal(ex_dividend_info info) { - var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + // 除权系数依赖除权登记日收盘价;调用方若在收盘前或使用非标准日期调用, + // EodPriceProvider 可能拿不到价格并返回无效系数,不能把该情况默认为 1。 + var dividendRate = GetDividendTaxRateDecimal(); var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice); var decimalClosePrice = (decimal)closePrice; - var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / - (10m + info.GiveShareAmount + info.RationedSharesAmount); - return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice; + return CalculateCorporateActionFactors(info, decimalClosePrice, dividendRate).PriceRatio; } /// @@ -766,12 +856,19 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { - var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); + // 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在 + // SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按 + // 送股和独立拆合股调整,现金分红和配股不增加持仓数量。 + var result = (decimal)amount + * (1m + info.GiveShareAmount / 10m) + * GetSplitFactor(info); return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } public IQueryable GetExDividendQuery(DateTime valueDate) { + // 该查询沿用作业的“日期已归一化”约定,要求 valueDate 与存量 ExDividendDate + // 同为当天 00:00;自然日业务键的时分秒兼容由保存路径 FindExDividendByBusinessKey 负责。 return DbContext.ex_dividend_info .Where(O => O.ValidStatus && O.ExDividendDate == valueDate); } @@ -810,7 +907,9 @@ namespace YLErp.Modules.TradeModule.DealModule public void ImportDividendInfos(Stream stream) { var dt = new ExcelHelper().ExcelToDataTable(stream, null, true); - if (!dt.Columns.Contains("股票代码") || !dt.Columns.Contains("股权登记日")) + if (!dt.Columns.Contains("股票代码") + || !dt.Columns.Contains("股权登记日") + || !dt.Columns.Contains("真实除权日")) { throw new ServiceException("请使用正确的模板上传"); } @@ -821,8 +920,13 @@ namespace YLErp.Modules.TradeModule.DealModule { UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(), ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue, + EffectiveDate = DateTime.TryParse( + getColValueFromTable(dt.Rows[i], "真实除权日"), out var effectiveDate) + ? effectiveDate + : (DateTime?)null, GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, + Split = decimal.TryParse(getColValueFromTable(dt.Rows[i], "拆/合股倍数"), out var split) ? split : (decimal?)null, RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, OptId = OptUser.UserId, @@ -841,6 +945,18 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行股权登记日不正确"); } + if (!info.EffectiveDate.HasValue) + { + throw new ServiceException($"第{i + 1}行真实除权日不正确"); + } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ServiceException($"第{i + 1}行拆/合股倍数必须大于0"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日"); + } dividendInfos.Add(info); } if (!AddDividendInfos(dividendInfos, out var errMsg)) @@ -900,6 +1016,17 @@ namespace YLErp.Modules.TradeModule.DealModule { target.RationedSharesPrice = source.RationedSharesPrice; } + if (source.Split.HasValue) + { + // Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。 + target.Split = source.Split.Value; + } + if (source.EffectiveDate.HasValue) + { + // EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。 + // 为空时不覆盖数据库已有值,避免旧记录在不完整导入中丢失真实生效日。 + target.EffectiveDate = source.EffectiveDate.Value.Date; + } } public bool AddDividendInfos(IEnumerable infos, out string errMsg) @@ -935,10 +1062,19 @@ namespace YLErp.Modules.TradeModule.DealModule errMsg = "股权登记日信息不存在"; return false; } + if (item.Split.HasValue && item.Split.Value <= 0m) + { + errMsg = "拆/合股倍数必须大于0"; + return false; + } // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 var exDividendDate = item.ExDividendDate.Value.Date; + if (item.EffectiveDate.HasValue) + { + item.EffectiveDate = item.EffectiveDate.Value.Date; + } var businessKey = (underlying.id, exDividendDate); if (item.id > 0 && recordKeys.TryGetValue(item.id, out var existingRecordKey) @@ -954,6 +1090,12 @@ namespace YLErp.Modules.TradeModule.DealModule item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6); item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6); item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6); + if (item.Split.HasValue) + { + // 拆合股比例可能为 0.01、0.001 等小数,保留 12 位避免导入时 + // 被 6 位金额精度截断;日期字段则在上方统一归一化为自然日。 + item.Split = OtcFormatHelper.FormatValue(item.Split.Value, 12); + } // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录 // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。 @@ -1082,6 +1224,43 @@ namespace YLErp.Modules.TradeModule.DealModule /// public bool checkDividendInfoExecuteStatus(ex_dividend_info info) { + // TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定 + // 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为 + // 7 月 29 日)才禁止修改,避免修改后无法解释已落库的调整前后快照。 + if (info?.EffectiveDate.HasValue == true) + { + var effectiveDate = info.EffectiveDate.Value.Date; + var trsTradeIds = DbContext.trade + .Where(x => x.ValidState != ConsGlobal.InValid + && x.TradeType == "收益互换" + && x.UnderlyingCode == info.UnderlyingCode + && x.TradeDate <= effectiveDate + && x.ExerciseDate >= effectiveDate) + .Select(x => x.id) + .ToList(); + if (trsTradeIds.Count > 0) + { + // 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改 + // 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前 + // 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。 + var hasAppliedEod = DbContext.eod_swap_position.Any(x => + trsTradeIds.Contains(x.SwapTradeId) + && !x.Invalid + && x.UnderlyingCode == info.UnderlyingCode + && x.ValueDate >= effectiveDate); + if (hasAppliedEod) + { + return true; + } + + // EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。 + // 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的 + // 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断, + // 否则交易已回退仍会被错误判定为“已执行”而无法修改。 + return false; + } + } + var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any(); if (eodStatus) { diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index a10d96ac..536504ed 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -263,6 +263,10 @@ namespace YLErp.Modules.TradeModule.DealModule td.OptName = UserName; td.OptDate = OptDate; int passStatus = new ApprovalProcessService(OptUser).CheckTradeApprovalStep(td, OptUser.UserId); + if (passStatus != 0 && passStatus != -3) + { + new TradeApprovalOAService(this).EnsureForCurrentNode(td); + } if (passStatus == 0) { td.TradeStatus = ConsTrade.确认成交; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 3755dc1b..9cdda1c5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -56,6 +56,23 @@ namespace YLErp.Modules.TradeModule.DealModule throw new ServiceException("该交易状态已变更,不能被审批,请先刷新页面"); } + if (!req.isFromOa && (req.status == "pass" || req.status == "reject")) + { + var currentNode = TradeProcessByCategory(td) + .Where(x => x.order == td.ProcessOrderId && (x.node == 0 || x.node == td.ProcessOrderBranch)) + .OrderByDescending(x => x.node == td.ProcessOrderBranch) + .FirstOrDefault(); + if (currentNode?.isOaApproval == true) + { + var oa = DbContext.tradeApprovalOaResult.FirstOrDefault(x => x.trade_id == td.id + && x.approval_process_id == currentNode.id + && x.is_valid + && (x.status == "提交成功" || x.status == "同步中" || x.status == "归档中")); + if (oa != null && !new TradeApprovalOAService(this).ArchiveForLocalAction(oa, out var errorMessage)) + throw new ServiceException(errorMessage); + } + } + if (req.status == "pass") { return TradePass(req, td); @@ -179,6 +196,13 @@ namespace YLErp.Modules.TradeModule.DealModule return result; } + // 关联 OA 的节点必须先成功创建 OA 流程,才允许本地审批推进到该节点。 + if (nextOrder.isOaApproval && td.TradeType == "收益互换") + { + // OA 创建是节点通过后的外部动作;明确失败只记录在 OA 结果表,不能回滚本地节点通过。 + new TradeApprovalOAService(this).CreateForNode(td, nextOrder); + } + //----------------------------------------------- // 继续审批流转 //----------------------------------------------- @@ -788,6 +812,7 @@ namespace YLErp.Modules.TradeModule.DealModule public bool notNeedOperationHistory; public bool isPartialExercise; public bool isExpire = false; + public bool isFromOa; } /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs index c543d745..661a8c6e 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs @@ -50,6 +50,11 @@ return; } + foreach (var td in revokeTrades) + { + ArchiveActiveOaOrThrow(td); + } + var tradeids = revokeTrades.Select(n => n.id); var revokeTrade_cashs = DbContext.trade_cash.Where(c => c.ValidState == "InValid" && !c.IsDeleted && tradeids.Contains(c.TradeId)).ToList(); revokeTrade_cashs.ForEach(x => @@ -94,6 +99,7 @@ var Withdraws = DbContext.trade.Where(t => tradeIds.Contains(t.id)).ToList(); foreach (var td in Withdraws) { + ArchiveActiveOaOrThrow(td); var changesTradeStatus = DbContext.TradeAuditLog.Where(x => (x.OptType == "确认交易" || x.OptType == "交易特批-确认交易") && x.TradeId == td.id).OrderByDescending(x => x.id)?.FirstOrDefault()?.Changes; td.TradeStatus = changesTradeStatus == ConsTrade.新增待确认 ? ConsTrade.新增待确认 : ConsTrade.修改待确认; td.CheckStatus = null; @@ -103,6 +109,12 @@ } DbContext.SaveChanges(); } + + private void ArchiveActiveOaOrThrow(trade td) + { + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(td, out var errorMessage)) + throw new ServiceException(errorMessage); + } /// /// 获取包含黑箱结构的子交易集合 /// diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs index 2a66f0a8..26b32983 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/SwapSettlementBillGenerateService.cs @@ -53,6 +53,12 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule var allFlowEvents = DbContext.swap_flow_event.Where(x => eventIds.Contains(x.EventId)).ToList(); var tradeContracts = DbContext.trade_contract_r.Where(x=> tradeIds.Contains(x.TradeId)&&x.Type== ContractTypeEnum.Clearing&&x.IsValid).ToList(); flowEvents = newFlowEvents.ToList(); + // 定位要点:生成范围按【客户+平仓日】扩张——传入的是用户勾选的 flowEventIds, + // 实际生成会包含同客户同日全部平仓流水(PositionType>0)。若某笔交易未生成过交易确认书, + // TradeSettleBillGenerator 会整批抛"XX未生成交易确认书",此日志用于对齐"勾选了什么 vs 实际生成什么"。 + LogFactory.GetLogger("GenerateSingleV2").Info( + $"生成结算确认书-生成范围: 传入flowEventIds[{string.Join(",", flowEventIds)}] " + + $"扩张后待生成[{string.Join(",", flowEvents.Select(f => $"{f.id}:{f.SwapTradeNo}"))}]"); //---------------------------------- // 交易确认书数据 //---------------------------------- diff --git a/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs new file mode 100644 index 00000000..c9342f95 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs @@ -0,0 +1,481 @@ +using BaseOUDAL; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Modules.ApiModule; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// 收益互换审批节点的国联民生 OA 对接。 + /// 本期只负责 HTTP 创建、查询和归档;审批结果由轮询任务回写本地流程。 + /// + public class TradeApprovalOAService : YLBaseService + { + private const string Pending = "提交成功"; + private const string Syncing = "同步中"; + private const string Ending = "归档中"; + private readonly IYcLogger _logger = LogFactory.GetLogger(); + + public TradeApprovalOAService(OptUserInfo userInfo) : base(userInfo) { } + public TradeApprovalOAService(YLBaseService baseService) : base(baseService) { } + + public bool IsSupported(trade trade) + { + return trade != null && trade.TradeType == "收益互换"; + } + + public trade_approval_oa_result CreateForNode(trade trade, approvalprocess node) + { + if (!IsSupported(trade) || node == null || !node.isOaApproval) + return null; + + var scenario = node.processType == "TradeProcess" + ? "开仓" + : (trade.TradeStatus == ConsTrade.平仓待复核 ? "平仓" : "收益结算"); + _logger.Info($"开始创建 OA 移动审批,交易:{trade.id},节点:{node.id},场景:{scenario}"); + var record = new trade_approval_oa_result + { + trade_id = trade.id, + approval_process_id = node.id, + process_type = node.processType, + applicant_id = UserId, + applicant_login_name = Config("Applicant"), + status = "提交中", + is_valid = true + }; + record.SetOpt(UserInfo); + DbContext.tradeApprovalOaResult.Add(record); + DbContext.SaveChanges(); + + try + { + var creator = Config("Creator"); + var applicant = Config("Applicant"); + if (string.IsNullOrWhiteSpace(creator) || string.IsNullOrWhiteSpace(applicant)) + throw new ServiceException("OA 配置缺少 Creator 或 Applicant"); + + var spr = GetSprLoginName(node); + var payload = BuildPayload(trade, creator, applicant, spr, scenario); + record.request_payload = JsonHelper.Serialize(payload); + var responseText = Post("SubUrl", payload); + record.last_response = responseText; + var response = JObject.Parse(responseText); + var responseData = response["data"] as JObject; + var responseCode = (int?)response["code"]; + var responseStatus = (string)response["status"]; + _logger.Info($"OA 创建流程原始响应,交易:{trade.id},节点:{node.id},响应:{responseText}"); + if ((int?)response["code"] != 0 || !string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + record.status = "提交失败"; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"] ?? "OA 创建流程失败"; + _logger.Error($"创建 OA 移动审批返回失败,交易:{trade.id},节点:{node.id},code:{responseCode},status:{responseStatus}"); + } + else + { + record.status = Pending; + record.oa_fileid = (string)responseData?["requestid"] ?? (string)responseData?["requestId"]; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"]; + if (string.IsNullOrWhiteSpace(record.oa_fileid)) + { + record.status = "提交失败"; + _logger.Error($"创建 OA 移动审批未返回 requestId,交易:{trade.id},节点:{node.id}"); + } + else + { + _logger.Info($"创建 OA 移动审批成功,交易:{trade.id},节点:{node.id},requestId:{record.oa_fileid}"); + } + } + } + catch (Exception ex) + { + record.status = "提交失败"; + record.oa_msg = ex.Message; + _logger.Error($"创建 OA 移动审批失败,交易:{trade.id},节点:{node.id}", ex); + } + DbContext.SaveChanges(); + return record; + } + + /// + /// 交易首次进入审批时,确保当前 OA 节点已创建对应流程。 + /// + public trade_approval_oa_result EnsureForCurrentNode(trade trade) + { + if (!IsSupported(trade) || trade.ProcessOrderId <= 0) + return null; + + var node = GetCurrentNode(trade); + if (node?.isOaApproval != true) + return null; + + var hasActiveRecord = DbContext.tradeApprovalOaResult.Any(x => x.trade_id == trade.id + && x.approval_process_id == node.id + && x.is_valid + && (x.status == "提交中" || x.status == Pending || x.status == Syncing || x.status == Ending)); + return hasActiveRecord ? null : CreateForNode(trade, node); + } + + /// + /// 本地撤回前归档交易仍在审批中的 OA 流程。 + /// + public bool ArchiveActiveForTrade(trade trade, out string errorMessage) + { + errorMessage = null; + if (!IsSupported(trade)) + return true; + + var records = DbContext.tradeApprovalOaResult + .Where(x => x.trade_id == trade.id + && x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (x.status == Pending || x.status == Syncing || x.status == Ending)) + .OrderBy(x => x.id) + .ToList(); + if (records.Count == 0) + { + _logger.Info($"本地撤回前未找到待归档 OA 流程,交易:{trade.id}"); + return true; + } + + foreach (var record in records) + { + if (!ArchiveForLocalAction(record, out errorMessage)) + return false; + } + return true; + } + + /// + /// 本地审批或撤回前归档 OA。通过条件更新抢占记录,避免与轮询回写同时推进同一节点。 + /// + public bool ArchiveForLocalAction(trade_approval_oa_result record, out string errorMessage) + { + errorMessage = null; + if (record == null) + return true; + + _logger.Info($"本地操作前开始归档 OA 流程,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + + if (!TryChangeStatus(record.id, Pending, Ending, null)) + { + _logger.Info($"本地操作前归档 OA 流程未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 审批结果正在同步,请稍后再试"; + return false; + } + + if (!ForceEnd(record.oa_fileid)) + { + TryChangeStatus(record.id, Ending, Pending, "OA 流程归档失败,等待重试"); + _logger.Error($"本地操作前归档 OA 流程失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 流程归档失败,暂不能执行本地审批操作"; + return false; + } + + TryChangeStatus(record.id, Ending, "已归档", "本地操作前已归档 OA 流程"); + _logger.Info($"本地操作前归档 OA 流程成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + return true; + } + + public JObject Query(string requestId) + { + var query = "requestId=" + Uri.EscapeDataString(requestId ?? string.Empty) + + "&systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + var queryUrl = Config("TradeApprovalQueryUrl"); + if (string.IsNullOrWhiteSpace(queryUrl)) + throw new ServiceException("OA 配置缺少 TradeApprovalQueryUrl"); + var url = queryUrl + (queryUrl.Contains("?") ? "&" : "?") + query; + var client = new HttpClientWrap(Config("BaseUrl"), 60); + return JObject.Parse(client.Get(url, null)); + } + + public bool ForceEnd(string requestId) + { + try + { + // 客户 OA 的 requestId 由 @RequestParam 接收,必须放在 URL QueryString。 + var response = JObject.Parse(Post("ForceEndUrl", new { }, requestId)); + var code = (int?)response["code"]; + var status = (string)response["status"]; + var responseData = response["data"] as JObject; + var resultCode = (string)responseData?["resultcode"]; + var success = code == 0 + && string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase) + && resultCode == "0"; + _logger.Info($"OA 强制归档返回,requestId:{requestId},code:{code},status:{status},resultcode:{resultCode},success:{success}"); + return success; + } + catch (Exception ex) + { + _logger.Error($"归档 OA 流程失败,requestId:{requestId}", ex); + return false; + } + } + + public List SyncPendingStatuses(string requestId = null) + { + var messages = new List(); + var staleAt = DateTime.Now.AddMinutes(-5); + var pending = DbContext.tradeApprovalOaResult + .Where(x => x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (string.IsNullOrWhiteSpace(requestId) || x.oa_fileid == requestId) + && (x.status == Pending || (x.status == Syncing && (x.last_query_time == null || x.last_query_time < staleAt)))) + .ToList(); + _logger.Info($"开始轮询 OA 移动审批状态,requestId:{requestId ?? "全部"},待查询数量:{pending.Count}"); + foreach (var record in pending) + { + var claimed = false; + try + { + _logger.Info($"查询 OA 移动审批状态,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + var response = Query(record.oa_fileid); + record.last_response = response.ToString(); + var flow = response["data"] is JArray data + ? data.FirstOrDefault(x => (string)x["requestId"] == record.oa_fileid) as JObject + : null; + var flowStatusType = (string)flow?["flowStatusType"]; + var flowNode = (string)flow?["flowNode"]; + var isReturned = flowStatusType == "0" && flowNode == "退回"; + // 1=批准;3=OA 批准后的自然归档。TRS 主动归档的记录不会进入本次查询。 + var isApproved = flowStatusType == "1" || flowStatusType == "3"; + _logger.Info($"OA 移动审批查询结果,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},flowStatusType:{flowStatusType},flowNode:{flowNode},判定:{(isReturned ? "退回" : isApproved ? "通过" : "等待")}"); + if (!isApproved && !isReturned) + { + record.last_query_time = DateTime.Now; + DbContext.SaveChanges(); + continue; + } + + DbContext.SaveChanges(); + if (!TryClaimForSync(record.id)) + { + _logger.Info($"OA 审批结果同步未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + continue; + } + claimed = true; + + var trade = DbContext.trade.Find(record.trade_id); + if (trade == null) + { + SetClaimedStatus(record.id, "同步失败", "交易不存在"); + continue; + } + if (GetCurrentNode(trade)?.id != record.approval_process_id) + { + SetClaimedStatus(record.id, "同步忽略", "当前审批节点已变化,忽略 OA 结果"); + continue; + } + var result = new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = trade.id, + status = isReturned ? "reject" : "pass", + comments = isReturned ? "OA 移动审批退回" : "OA 移动审批通过", + isFromOa = true, + notNeedOperationHistory = false + }); + if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) + { + SetClaimedStatus(record.id, Pending, result.ErrorMsg); + _logger.Error($"OA 审批结果回写本地失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},原因:{result.ErrorMsg}"); + } + else + { + SetClaimedStatus(record.id, isReturned ? "退回" : "通过", null); + _logger.Info($"OA 审批结果回写本地成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},结果:{(isReturned ? "退回" : "通过")}"); + } + messages.Add(record.trade_id + ":" + (string.IsNullOrWhiteSpace(result.ErrorMsg) ? (isReturned ? "退回" : "通过") : Pending)); + } + catch (Exception ex) + { + if (claimed) + { + SetClaimedStatus(record.id, Pending, ex.Message); + } + else + { + record.last_query_time = DateTime.Now; + record.oa_msg = ex.Message; + DbContext.SaveChanges(); + } + _logger.Error($"轮询 OA 审批状态失败,requestId:{record.oa_fileid}", ex); + } + } + _logger.Info($"OA 移动审批状态轮询结束,已处理数量:{messages.Count}"); + return messages; + } + + private string Post(string urlKey, object payload, string requestId = null) + { + var path = Config(urlKey); + if (string.IsNullOrWhiteSpace(path)) + path = urlKey == "ForceEndUrl" ? "/gateway/oaflow/forceEndOaFlow" : "/gateway/oaflow/createOaFlow"; + var query = "systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + if (urlKey == "ForceEndUrl") + query += "&requestId=" + Uri.EscapeDataString(requestId ?? string.Empty); + else + query += "&flowId=" + Uri.EscapeDataString(Config("MobileFlowId", "245103")) + + "&isnextflow=" + Uri.EscapeDataString(Config("TradeApprovalIsNextFlow", "0")); + var url = path + (path.Contains("?") ? "&" : "?") + query; + return new HttpClientWrap(Config("BaseUrl"), 60).PostJson(url, payload, null); + } + + private string Config(string key, string fallback = "") + { + if (key == "BaseUrl" || key == "SubUrl" || key == "TradeApprovalQueryUrl" || key == "ForceEndUrl") + { + var mockEnabled = bool.TryParse(AppManager.GetConfiguration()["TradeApprovalOaMock:Enabled"], out var enabled) && enabled; + var mockValue = AppManager.GetConfiguration()[$"TradeApprovalOaMock:{key}"]; + if (mockEnabled && !string.IsNullOrWhiteSpace(mockValue)) + return mockValue; + } + var value = AppManager.GetConfiguration()[$"oa_confg:{key}"]; + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + + private approvalprocess GetCurrentNode(trade trade) + { + if (trade == null) return null; + var processType = IsCloseScenario(trade) ? "CloseProcess" : "TradeProcess"; + return DbContext.approvalprocess + .Where(x => x.processType == processType + && x.order == trade.ProcessOrderId + && (x.node == trade.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == trade.ProcessOrderBranch) + .FirstOrDefault(); + } + + private static bool IsCloseScenario(trade trade) + { + return trade.TradeStatus == ConsTrade.平仓待复核 + || trade.TradeStatus == ConsTrade.行权待复核 + || trade.TradeStatus == ConsTrade.互换待复核; + } + + private bool TryClaimForSync(int recordId) + { + var now = DateTime.Now; + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, last_query_time = {1} " + + "WHERE id = {2} AND is_valid = 1 AND (status = {3} OR (status = {0} AND (last_query_time IS NULL OR last_query_time < {4})))", + Syncing, now, recordId, Pending, now.AddMinutes(-5)) == 1; + } + + private bool TryChangeStatus(int recordId, string expectedStatus, string targetStatus, string message) + { + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + targetStatus, message, recordId, expectedStatus) == 1; + } + + private void SetClaimedStatus(int recordId, string status, string message) + { + DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + status, message, recordId, Syncing); + } + + private string GetSprLoginName(approvalprocess node) + { + using var baseDb = DbContextFactory.GetErpBaseContext(); + var users = (from roleUser in baseDb.RoleUsers + join user in baseDb.SystemUsers on roleUser.UserId equals user.Id + where roleUser.RoleId == node.roleId && user.State == (int)UserState.Enabled + select user.LoginName).ToList(); + if (users.Count == 0) + throw new ServiceException($"关联 OA 的审批角色未配置启用用户,角色ID:{node.roleId}"); + if (users.Count > 1) + throw new ServiceException($"关联 OA 的审批角色配置了多个启用用户,角色ID:{node.roleId}"); + if (string.IsNullOrWhiteSpace(users[0])) + throw new ServiceException($"关联 OA 的审批用户未配置登录名,角色ID:{node.roleId}"); + return users[0]; + } + + private Dictionary BuildPayload(trade trade, string creator, string applicant, string spr, string scenario) + { + var positions = DbContext.swap_position + .Where(x => x.SwapTradeId == trade.id && x.IsInitial && !x.Invalid) + .ToList(); + var floatPosition = positions.FirstOrDefault(x => x.IsInitial && !string.IsNullOrWhiteSpace(x.UnderlyingCode)); + var underlyingName = floatPosition == null + ? string.Empty + : DbContext.underlying_manager.Where(x => x.UnderlyingCode == floatPosition.UnderlyingCode) + .Select(x => x.UnderlyingName) + .FirstOrDefault() ?? string.Empty; + var interestPositions = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode) + && x.InterestMode != (int)InterestModeEnum.初始预付金 + && x.InterestMode != (int)InterestModeEnum.追加预付金 + && x.InterestMode != (int)InterestModeEnum.Unknown).ToList(); + var marginPositions = positions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + || x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); + var tradeDate = trade.TradeDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var startDate = floatPosition?.PosiStartDate.ToString("yyyy-MM-dd") ?? string.Empty; + var maturityDate = floatPosition?.PosiMatuirityDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var contractDays = floatPosition?.PosiMatuirityDate == null + ? string.Empty + : ((floatPosition.PosiMatuirityDate.Value.Date - floatPosition.PosiStartDate.Date).Days).ToString(); + + var elementLines = new List + { + "交易日期=" + tradeDate, + "审批角色=衍生品负责人", + "交易编号=" + (trade.TradeNumber ?? string.Empty), + "对手方=" + (trade.ClientName ?? string.Empty), + "交易类型=" + scenario, + "浮动端多空头=" + (floatPosition == null ? string.Empty : floatPosition.PositionType == 1 ? "多头" : "空头"), + "利息端方向=" + JoinValues(interestPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")), + "簿记账户=" + (trade.AssetBookName ?? string.Empty), + "结构类型=" + (trade.StructureType ?? string.Empty), + "标的代码=" + (floatPosition?.UnderlyingCode ?? string.Empty), + "标的名称=" + underlyingName, + "期初收益率=" + (floatPosition?.InitYtm?.ToString("0.####%") ?? string.Empty), + "期初价格=" + (floatPosition?.PosiNetPrice.ToString("0.####") ?? string.Empty), + "行权方式=" + (trade.GetExerciseModeCn() ?? string.Empty), + "合约期限=" + contractDays, + "数量=" + (floatPosition?.PosiQuantity.ToString("0.####") ?? string.Empty), + "起始日=" + startDate, + "到期日=" + maturityDate, + "交易员=" + (trade.TraderName ?? string.Empty), + "支付日期=" + JoinValues(marginPositions.Select(x => x.HappenDate?.ToString("yyyy-MM-dd") ?? string.Empty)), + "期初预付金=" + JoinValues(marginPositions.Select(x => x.InterestPrincipalFix.ToString("0.00"))), + "期初预付金方向=" + JoinValues(marginPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")) + }; + + if (scenario == "开仓") + { + elementLines.Add("平仓金额="); + elementLines.Add("平仓日期="); + } + else + { + var eventType = scenario == "平仓" ? (int)SwapEventTypeEnum.平仓 : (int)SwapEventTypeEnum.互换; + var swapEvent = DbContext.swap_event.Where(x => x.SwapTradeId == trade.id && !x.Invalid && x.EventType == eventType) + .OrderByDescending(x => x.id).FirstOrDefault(); + var unwind = string.IsNullOrWhiteSpace(swapEvent?.EventData) ? null : JsonHelper.Deserialize(swapEvent.EventData); + elementLines.Add("平仓金额=" + (unwind?.SwapCloseAmount.ToString("0.##") ?? "")); + elementLines.Add("平仓日期=" + (unwind?.UnwindDate?.ToString("yyyy-MM-dd") ?? swapEvent?.ValueDate.ToString("yyyy-MM-dd") ?? "")); + } + + var elements = string.Join("
", elementLines); + var payload = new Dictionary + { + ["title"] = "衍生品系统TRS移动审批-" + trade.TradeNumber, + ["creater"] = creator, + ["applicant"] = applicant, + ["spr"] = spr, + ["applyDate"] = DateTime.Now.ToString("yyyy-MM-dd"), + ["elements"] = elements + }; + return payload; + } + + private static string JoinValues(IEnumerable values) + { + return string.Join(";", values.Where(x => !string.IsNullOrWhiteSpace(x))); + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 62e42170..004baef5 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -287,6 +287,13 @@ namespace YLErp.BLL .Select(a => new { a.order, a.roleId }) .ToList() .ToDictionary(a => a.order, a => a.roleId); + var approvalNodes = db.approvalprocess + .Select(a => new { a.id, a.processType, a.order, a.node }) + .ToList(); + var oaRecords = db.tradeApprovalOaResult + .Where(x => swapTradeIds.Contains(x.trade_id) && x.is_valid) + .Select(x => new { x.id, x.trade_id, x.approval_process_id, x.status, x.oa_msg }) + .ToList(); foreach (var tradeLinq in retListResult.rows) { @@ -305,6 +312,29 @@ namespace YLErp.BLL tradeLinq.ProcessRoleName = name; } + if (tradeLinq.TradeType == "收益互换") + { + var processType = tradeLinq.TradeStatus == "平仓待复核" || tradeLinq.TradeStatus == "行权待复核" || tradeLinq.TradeStatus == "互换待复核" + ? "CloseProcess" + : "TradeProcess"; + var currentNode = approvalNodes + .Where(x => x.processType == processType + && x.order == tradeLinq.ProcessOrderId + && (x.node == tradeLinq.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == tradeLinq.ProcessOrderBranch) + .FirstOrDefault(); + var oa = currentNode == null ? null : oaRecords + .Where(x => x.trade_id == tradeLinq.id && x.approval_process_id == currentNode.id) + .OrderByDescending(x => x.id) + .FirstOrDefault(); + if (oa != null) + { + tradeLinq.OaRemark = string.IsNullOrWhiteSpace(oa.oa_msg) + ? oa.status + : oa.status + ":" + oa.oa_msg; + } + } + if (tradeLinq.TradeType == "远期" || tradeLinq.TradeType == "掉期") { var option = db.trade_forward.FirstOrDefault(x => x.TradeId == tradeLinq.id); diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index 7d45a901..aa715929 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -120,6 +120,7 @@ namespace YLErp.Modules.TradeModule // 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。 // 若所有节点均不满足 → 直接审批通过(无需任何人审核)。 ApplyTriggerFromStart(tradeProcess, td, userId); + new TradeApprovalOAService(this).EnsureForCurrentNode(td); } /// diff --git a/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx b/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx index b5d20fdd..d04a1e22 100644 Binary files a/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx and b/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx differ diff --git a/YLErpWeb/Controllers/BondController.cs b/YLErpWeb/Controllers/BondController.cs index 36ef4f85..9a367b76 100644 --- a/YLErpWeb/Controllers/BondController.cs +++ b/YLErpWeb/Controllers/BondController.cs @@ -7,8 +7,12 @@ namespace YLErp.Web.Controllers { [AllowAnonymous] - public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null) + public JsonResult CalcBond(string underlyingCode, decimal price, string priceType, string targetDate = null, string source = null) { + // EQD-6953:source 标记调用场景(unwind=平仓页;录入页不传)。BondCalcHepler 已记录 + // 完整请求参数与成败结果,此处仅补场景维度,定位问题时先按 source 区分入口再看参数。 + LogFactory.GetLogger("BondController").Info( + $"CalcBond source={source ?? "(未标记)"} underlyingCode={underlyingCode} price={price} priceType={priceType} targetDate={targetDate ?? "(空→代理默认T+1)"}"); string errorMsg; var obj = BondCalcHepler.BondCalc(underlyingCode, price, priceType, out errorMsg, targetDate); if (obj == null) diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 33c82881..479e70ab 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -393,6 +393,32 @@ namespace YLErp.Web.Controllers new SwapTradeService(CurUser).TradeBack(model.TradeId, model.TradeDate); return JsonSuccess("回退成功"); } + /// + /// 根据平仓日期刷新持仓基线(处理公司行为除权) + /// + /// 交易ID + /// 事件日期/平仓日期 + /// 返回最新的持仓数量、价格等基线数据 + public JsonResult RefreshUnwindBaseline(int tradeId, DateTime valueDate) + { + var (positionQty, posiNotionalValue, posiGrossPrice, posiNetPrice, isRestored) = + new SwapDealService(CurUser).RefreshFloatLegBaseline(tradeId, valueDate); + + var result = new + { + PositionQty = positionQty, + PosiNotionalValue = posiNotionalValue, + PosiGrossPrice = posiGrossPrice, + PosiNetPrice = posiNetPrice, + IsRestored = isRestored, + // 全平时,平仓数量等于持仓数量 + CloseQty = positionQty, + CloseNotionalValue = posiNotionalValue + }; + + return JsonSuccess("", result); + } + /// /// 平仓利息腿信息 /// @@ -1207,6 +1233,42 @@ namespace YLErp.Web.Controllers return JsonSuccess(result); } + /// + /// EQD-5320 批量发送结算确认书邮件——经服务端代理 bond-oms(BondOmsInterface_BaseUrl 出口), + /// 替代前端直连 /trs_hub_api 反向代理(未配 nginx 的环境 404)。 + /// + /// 平仓流水id,逗号分隔;单个令牌兼容【数字】与【EncryptId 加密串】两种形态 + /// (批量按钮传网格数字id,单行按钮传 swap_flow_event.EncryptId——全站 enid 惯例) + [HttpPost] + public JsonResult BatchSendSettleEmail(string swapFlowEventIds) + { + if (string.IsNullOrWhiteSpace(swapFlowEventIds)) + { + return JsonError("请选择交易"); + } + var ids = new List(); + foreach (var s in swapFlowEventIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var token = s.Trim(); + long id; + if (!long.TryParse(token, out id) || id <= 0) + { + // 非数字令牌按 EncryptId 解密(解密失败/非法串返回0,不抛出) + try { id = DecryptLong(token); } catch { id = 0; } + } + if (id > 0) + { + ids.Add(id); + } + } + if (ids.Count == 0) + { + return JsonError("平仓流水id解析为空(既非数字也非有效加密ID):" + swapFlowEventIds); + } + var result = new SwapEndConfirmService(CurUser).BatchSendSettleEmail(ids); + return string.IsNullOrEmpty(result) ? JsonSuccess("发送成功") : JsonError(result); + } + } } diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index a366b8ae..e8db3bb8 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -46,14 +46,28 @@ namespace YLErp.Web.Controllers { throw new FormatException("未收到参数"); } - if (string.IsNullOrWhiteSpace(info.UnderlyingCode) || info.ExDividendDate == null) + if (string.IsNullOrWhiteSpace(info.UnderlyingCode) + || info.ExDividendDate == null + || info.EffectiveDate == null) { - throw new FormatException("标的代码或股权登记日信息不存在!"); + throw new FormatException("标的代码、股权登记日或真实除权日信息不存在!"); + } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new FormatException("拆/合股倍数必须大于0!"); } if (QdpCalendarHelper.IsHoliday(info.ExDividendDate.Value)) { throw new FormatException("股权登记日不应为非交易日!"); } + if (QdpCalendarHelper.IsHoliday(info.EffectiveDate.Value)) + { + throw new FormatException("真实除权日不应为非交易日!"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new FormatException("真实除权日不应早于股权登记日!"); + } var status = new DividendService(CurUser).AddDividendInfos(new[] { info }, out var errMsg); if (!status) { diff --git a/YLErpWeb/Controllers/underlying_managerController.cs b/YLErpWeb/Controllers/underlying_managerController.cs index 851b449b..60dda498 100644 --- a/YLErpWeb/Controllers/underlying_managerController.cs +++ b/YLErpWeb/Controllers/underlying_managerController.cs @@ -473,6 +473,26 @@ namespace YLErp.Web.Controllers { return JsonError("资产类型 必须填写"); } + + model.EtfSubType = model.EtfSubType?.Trim(); + if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund && string.IsNullOrEmpty(model.EtfSubType)) + { + return JsonError("ETF 子类 必须填写"); + } + if (model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + { + + var isValidEtfSubType = DictionaryBLL.GetDictionaryItems("ETF 子类", model.EtfSubType).Any(); + if (!isValidEtfSubType) + { + return JsonError("ETF 子类 无效,请从字典选项中选择"); + } + } + if (model.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + + model.EtfSubType = null; + } if (ConsGlobal.InstrumentType.IsBond(model.UnderlyingInstrumentType)) { model.ExJson = JsonHelper.Serialize(model.Bond); diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index a2bbcced..476cf5e0 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -334,6 +334,9 @@ 审批规则 +
+ +
审批条件 @@ -424,7 +427,10 @@
审批规则 - + +
+
+
@@ -527,6 +533,9 @@ 审批规则
+
+ +
审批条件 @@ -617,7 +626,10 @@
审批规则 - + +
+
+
diff --git a/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml b/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml index 70c6aa12..1cf2744f 100644 --- a/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml +++ b/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml @@ -16,14 +16,14 @@ - + 操作时间 操作人 操作内容 - 说明 + 说明(含公司行为前后要素) @@ -31,7 +31,7 @@ {{dateFormat(item.OptTime,'YYYY-MM-DD HH:mm:ss')}} {{item.OptName}} {{item.EventTypeName}} - {{item.EventReason}} + {{item.EventReason}} diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 69344c11..f14749ab 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -26,6 +26,8 @@ + + }
@@ -197,6 +199,8 @@ 期初标的价格 期末标的交割全价% 期末标的价格 + @* EQD-6953 期末标的结算收益率:仅普通债券类收益互换显示,期末交割全价回车反算,可手填覆盖 *@ + 期末标的结算收益率% 数量 交易费用(平仓) 交易费用(待结算) @@ -223,11 +227,17 @@ {{priceFormat(floatPosition.PosiGrossPrice)}} - + + AUTOREV + @* EQD-6953 期末标的结算收益率:回车以它为源反算交割全价(联动盈亏);手填未回车=REV人工输入 *@ + + + AUTOREV + {{formatQuantity(deal.CloseQty)}} diff --git a/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml b/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml index 28fd4d8a..e9290c4e 100644 --- a/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml +++ b/YLErpWeb/Views/SwapTrade2/tradeEndConfirmList.cshtml @@ -31,6 +31,7 @@ + }
diff --git a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml index 6ea28715..67e3501d 100644 --- a/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml +++ b/YLErpWeb/Views/underlying_manager/underlying_managerEdit.cshtml @@ -8,6 +8,8 @@ showDeltaS_S = PS.Config.Is长江 }; var bond = Model.Bond ?? new UnderlyingBond(); + // ETF 子类下拉直接读取前端可维护字典;空选项用于新增页面的必填提示。 + var etfSubtypeItems = YLErp.BLL.DictionaryBLL.GetList("ETF 子类", true, underlying.EtfSubType); var blocks = new[] { underlying.Block1, underlying.Block2, underlying.Block3, underlying.Block4, underlying.Block5 }; for (var i = 1; i <= 5; i++) { @@ -28,13 +30,14 @@ } } - .None, .Stock, .CommodityFutures, .Bonds { + .None, .Stock, .CommodityFutures, .Bonds, .Fund { display: none; } .form-Stock .Stock, .form-CommodityFutures .CommodityFutures, - .form-Bonds .Bonds { + .form-Bonds .Bonds, + .form-Fund .Fund { display: block; } @@ -323,6 +326,31 @@
+ + @* 仅基金及基金专户维护基金管理人 *@ +
+ + +
+ + @* 重点功能:ETF 子类由系统字典维护,使用 Fund 类控制显隐,并固定放在表单最后 *@ +
+ + * +
@@ -334,4 +362,4 @@
- \ No newline at end of file + diff --git a/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml b/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml index 731c26d4..2b17d61a 100644 --- a/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml +++ b/YLErpWeb/Views/underlying_manager/underlying_managerView.cshtml @@ -49,6 +49,14 @@ @Html.MyDisplayFor(m => m.UnderlyingEnName) @Html.MyDisplayFor(m => m.BBGTicker) + + @if (Model.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + { + + @Html.MyDisplayFor(m => m.InvestAdvisorName) + @Html.MyDisplayFor(m => m.EtfSubType) + + } @Html.MyDisplayFor(m => m.UnderlyingType) @if (pageObj.showDeltaS_S) @@ -226,4 +234,4 @@
} -} \ No newline at end of file +} diff --git a/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs b/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs new file mode 100644 index 00000000..ffda966e --- /dev/null +++ b/YLErpWeb/WebAPI/Controllers/TradeApprovalOAJobController.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using YLErp.Helpers; +using YLErp.Model; +using YLErp.Modules.TradeModule; + +namespace YLErp.Web.WebAPI.Controllers +{ + /// 国联民生衍生品移动审批轮询入口,由 PowerJob 等外部调度器调用。 + [ApiController] + public class TradeApprovalOAJobController : ControllerBase + { + [HttpPost] + [Route("api/job/tradeApproval/syncOAStatus")] + public JsonResult SyncOAStatus([FromQuery] string requestId = null) + { + var configKey = AppManager.GetConfiguration()["JobConfig:ApiKey"]; + if (!string.IsNullOrEmpty(configKey) && Request.Headers["X-Job-Api-Key"].FirstOrDefault() != configKey) + return new JsonResult(new { code = 401, msg = "Invalid API Key" }); + + var user = new OptUserInfo(0, "系统移动审批同步", OptUserFrom.System); + var messages = new TradeApprovalOAService(user).SyncPendingStatuses(requestId); + return new JsonResult(new { code = 0, data = new { total = messages.Count, details = messages } }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + } +} diff --git a/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs b/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs new file mode 100644 index 00000000..e5bafa15 --- /dev/null +++ b/YLErpWeb/WebAPI/Controllers/TradeApprovalOaMockController.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using YLErp.Helpers; + +namespace YLErp.Web.WebAPI.Controllers +{ + /// + /// 仅供本地开发验证收益互换移动审批调用链的 OA 模拟接口。 + /// + [ApiController] + [Route("api/mock/tradeApprovalOa")] + public class TradeApprovalOaMockController : ControllerBase + { + private static readonly ConcurrentDictionary Flows = new(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + [HttpPost("createOaFlow")] + public IActionResult CreateOaFlow([FromBody] JsonElement payload) + { + if (!IsEnabled()) return NotFound(); + + var requestId = "mock-" + Guid.NewGuid().ToString("N"); + Flows[requestId] = new MockOaFlow { RequestId = requestId }; + return Json(new { code = 0, data = new { msg = string.Empty, requestid = requestId }, status = "SUCCESS" }); + } + + [HttpGet("getRequestData")] + public IActionResult GetRequestData([FromQuery] string requestId) + { + if (!IsEnabled()) return NotFound(); + + if (!Flows.TryGetValue(requestId ?? string.Empty, out var flow)) + return Json(new { code = 0, data = Array.Empty(), status = "SUCCESS" }); + + return Json(new + { + code = 0, + data = new[] { new { requestId = flow.RequestId, flowStatusType = flow.FlowStatusType, status = flow.Status } }, + status = "SUCCESS" + }); + } + + [HttpPost("forceEndOaFlow")] + public IActionResult ForceEndOaFlow([FromQuery] string requestId) + { + if (!IsEnabled()) return NotFound(); + + if (requestId != null && Flows.TryGetValue(requestId, out var flow)) + { + flow.FlowStatusType = "3"; + flow.Status = "归档"; + flow.IsEnded = true; + } + + return Json(new { code = 0, data = new { resultcode = "0" }, status = "SUCCESS" }); + } + + [HttpPost("setStatus")] + public IActionResult SetStatus([FromQuery] string requestId, [FromQuery] string flowStatusType, [FromQuery] string status = null) + { + if (!IsEnabled()) return NotFound(); + if (flowStatusType != "0" && flowStatusType != "1" && flowStatusType != "2" && flowStatusType != "3") + return BadRequest(new { code = 1, msg = "flowStatusType 只能为 0、1 或 2" }); + if (!Flows.TryGetValue(requestId ?? string.Empty, out var flow)) + return NotFound(new { code = 1, msg = "未找到 requestId" }); + if (flow.IsEnded) + return BadRequest(new { code = 1, msg = "OA 流程已归档" }); + + flow.FlowStatusType = flowStatusType; + if (!string.IsNullOrWhiteSpace(status)) + flow.Status = status; + return Json(new { code = 0, data = new { requestId, flowStatusType, status = flow.Status }, status = "SUCCESS" }); + } + + [HttpGet("flows")] + public IActionResult GetFlows() + { + if (!IsEnabled()) return NotFound(); + + return Json(new + { + code = 0, + data = Flows.Values.Select(x => new { requestId = x.RequestId, flowStatusType = x.FlowStatusType, status = x.Status, isEnded = x.IsEnded }), + status = "SUCCESS" + }); + } + + private static bool IsEnabled() + { + return bool.TryParse(AppManager.GetConfiguration()["TradeApprovalOaMock:Enabled"], out var enabled) && enabled; + } + + private JsonResult Json(object value) + { + return new JsonResult(value, JsonOptions); + } + + private sealed class MockOaFlow + { + public string RequestId { get; set; } + public string FlowStatusType { get; set; } = "0"; + public string Status { get; set; } = "创建"; + public bool IsEnded { get; set; } + } + + } +} diff --git a/YLErpWeb/appsettings.dev.json b/YLErpWeb/appsettings.dev.json index 57e00050..ecceba98 100644 --- a/YLErpWeb/appsettings.dev.json +++ b/YLErpWeb/appsettings.dev.json @@ -78,13 +78,26 @@ "BaseUrl": "http://10.250.202.40:13590", "SubUrl": "/gateway/oaflow/createOaFlow", "QueryUrl": "/gateway/oaflow/getRequestData", + "TradeApprovalQueryUrl": "/gateway/oaflow/getRequestDataPro", + "ForceEndUrl": "/gateway/oaflow/forceEndOaFlow", + "MobileFlowId": 245103, "DownloadFileUrl": "/gateway/oaflow/downloadSingleFile", "SystemToken": "QIkox8sv3rVLtiT1wmI4Dp8wSjX9D84Glkp121vRino9fLobWnDiy8ppmqJcv1676TYuxZsGrYelx0whRCMaPyums2VnzIYrpqCFf5tyc3/x22Elf25xgRmd5pH8tLcnPuVOjMrUf0GsoytQCGFkhnrPajhsKq/mSwYQ4OA=", //OA系统key "SystemName": "onederiv", - "IsNextFlow": 1, //是否自动提交 默认0 + "IsNextFlow": 1, // 交易确认书 OA 参数 + "TradeApprovalIsNextFlow": 0, // 收益互换移动审批 OA 参数 + "Creator": "guoj", // OA 流程创建人账号 + "Applicant": "guoj", // OA 流程申请人(sqr)账号 "FlowId": 779115, "UploadUrl": "http://10.250.202.40:13590/gateway/oaflow/uploadDoc" }, + "TradeApprovalOaMock": { + "Enabled": false, + "BaseUrl": "http://localhost:49462", + "SubUrl": "/api/mock/tradeApprovalOa/createOaFlow", + "TradeApprovalQueryUrl": "/gateway/oaflow/getRequestDataPro", + "ForceEndUrl": "/api/mock/tradeApprovalOa/forceEndOaFlow" + }, "GeneralSSO": { "enable": true, //是否启用通用SSO登录 "auth_url": "http://192.168.2.96:10117/sso/login", //SSO授权地址 diff --git a/YLErpWeb/appsettings.local.json b/YLErpWeb/appsettings.local.json index 01547973..a42f229b 100644 --- a/YLErpWeb/appsettings.local.json +++ b/YLErpWeb/appsettings.local.json @@ -77,10 +77,21 @@ "oa_confg": { "BaseUrl": "http://ip:port", "SubUrl": "", + "TradeApprovalQueryUrl": "", "UploadUrl": "/api/oa/upload", // OA附件上传接口路径 "SystemToken": "your-system-token", // OA系统鉴权Token "SystemName": "OTC_TRADE", // 调用方系统标识 - "IsNextFlow": 0 + "IsNextFlow": 1, // 交易确认书 OA 参数 + "TradeApprovalIsNextFlow": 0, // 收益互换移动审批 OA 参数 + "Creator": "", // OA 流程创建人账号 + "Applicant": "" // OA 流程申请人(sqr)账号 + }, + "TradeApprovalOaMock": { + "Enabled": false, + "BaseUrl": "http://localhost:49462", + "SubUrl": "/api/mock/tradeApprovalOa/createOaFlow", + "TradeApprovalQueryUrl": "/api/mock/tradeApprovalOa/getRequestData", + "ForceEndUrl": "/api/mock/tradeApprovalOa/forceEndOaFlow" }, "GeneralSSO": { "enable": true, //是否启用通用SSO登录 diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index 6595bdab..5fc69d0f 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -120,6 +120,24 @@ describe('边界:计算器未返回的值不覆盖、无变化不写', () => { }); }); +describe('疑似到期债券值域闸门:ytm=0 且 净/全价均为面值100(UAT 060203.IB 实证的退化形态)', () => { + test('三条件同时成立 → 命中闸门返回真实原因(不回写)', () => { + const resp = { cleanPrice: 100, dirtyPrice: 100, ytm: 0 }; + const msg = SwapCalc.getBondCalcErrorMessage(resp); + expect(msg).toContain('疑似已到期'); + expect(msg).toContain('手工填写'); + }); + test('正常券不命中 → 返回 null 放行回写', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 97.43, dirtyPrice: 100.00001369863016, ytm: 6.738278242318886 })).toBeNull(); + }); + test('ytm=0 但价格非面值(真实零息平价券)→ 不命中', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 99.5, dirtyPrice: 100, ytm: 0 })).toBeNull(); + }); + test('价格=面值 但 ytm 非 0(正常息票平价券)→ 不命中', () => { + expect(SwapCalc.getBondCalcErrorMessage({ cleanPrice: 100, dirtyPrice: 100.5, ytm: 3.2 })).toBeNull(); + }); +}); + describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 errCode 守卫)', () => { test('空响应 → 提示"无响应",且绝不回写', () => { expect(SwapCalc.getBondCalcErrorMessage(null)).toBe("债券计算器无响应,已保留手工输入"); diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js index eeeb4310..5d31102f 100644 --- a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -148,6 +148,15 @@ function loadVueApp(model, mockPost) { return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6)); } }, + // EQD-6953:平仓页现于 unwindSwapTrade.js 之前加载 unwindBondCalc.js(见 SwapUnwind.cshtml), + // dataFormat 提交取整会引用其守卫;此处给同款行为的最小 mock(真实实现由 unwindBondCalc.test.js 覆盖) + UnwindBondCalc: { + hasValue(v) { return v !== null && v !== undefined && v !== '' && !isNaN(v); }, + roundExitYtm(v) { return v; }, + applyManualEdit(s) { return s; } + }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js index d60ebdc6..f6877998 100644 --- a/YLErpWeb/fe-tests/jest.config.js +++ b/YLErpWeb/fe-tests/jest.config.js @@ -23,8 +23,13 @@ module.exports = { moduleDirectories: ['node_modules', '../wwwroot/Scripts'], // 覆盖率配置(--coverage 时生效) + // 已知问题:源码在 rootDir(fe-tests) 之外,babel 提供器因 transform:{} 无插桩器、 + // v8 提供器对 rootDir 逃逸路径无法归因(jest 29.7 实测,含 roots/绝对路径变体), + // 覆盖率表恒为 0 且门槛不触发。清单仍保留以固化意图,真实修复需重构 rootDir。 collectCoverageFrom: [ '../wwwroot/Scripts/app/swaptrade/swapCalc.js', + '../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js', + '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js', '../wwwroot/Scripts/fast/fastVue.base.js', // 逐步加入更多文件 ], diff --git a/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js new file mode 100644 index 00000000..d30ae970 --- /dev/null +++ b/YLErpWeb/fe-tests/swapPricePrecisionHelper.test.js @@ -0,0 +1,178 @@ +/** + * swapPricePrecisionHelper.test.js — 字符串精确十进制核心的特征测试 + * ============================================================================ + * 目的:swapPricePrecisionHelper.js 的组件事件(swapPriceInput.component.test.js)与 + * 配置接线(swapPrecisionConfig.test.js)已有测试,但【字符串精确十进制核心】 + * (shiftDecimal / roundDecimal / multiplyDecimal / getRule / format / + * roundForSubmit) 一直只有间接覆盖。精度即资损,这里用 golden 值冻结现状: + * 1. 全部走字符串数位运算,绕开 IEEE754 浮点陷阱(0.1*0.2、1.005.toFixed); + * 2. roundDecimal 是【绝对值上四舍五入】= AwayFromZero,与 unwindBondCalc + * 的 ExitYtm 约定同向; + * 3. roundDecimal 不补零(1.2 → '1.2'),补零是 formatCommon/formatFixed 的职责; + * 4. roundDecimal 对非法输入/负精度【原样返回不归一】,调用方别拿它当校验器; + * 5. getRule 的覆盖优先级:先查 defaults 有无该品种(没有直接 null),再看 + * main.swapPricePrecision 覆盖,覆盖非法则回落 defaults。 + * + * 做法:直接 require 源文件(jest v8 覆盖率据此插桩,readFileSync+new Function + * 的沙箱加载会让覆盖率统计看不见);配置覆盖用 global.main.swapPricePrecision + * 注入——模块内 global 绑定到 globalThis,与本测试文件的 global 同源, + * beforeEach/afterEach 清理防串扰。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest swapPricePrecisionHelper + */ +const helper = require('../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js'); + +function withConfig(config, extras) { + if (config !== undefined) global.main = { swapPricePrecision: config }; + if (extras && extras.otcformat) global.otcformat = extras.otcformat; +} + +beforeEach(() => { delete global.main; delete global.otcformat; }); +afterEach(() => { delete global.main; delete global.otcformat; }); + +describe('shiftDecimal:纯数位平移,不经浮点', () => { + + test('右移补零 / 左移进小数', () => { + expect(helper.shiftDecimal('1.5', 1)).toBe('15'); + expect(helper.shiftDecimal('1.5', -1)).toBe('0.15'); + expect(helper.shiftDecimal('12.34', -3)).toBe('0.01234'); + expect(helper.shiftDecimal('0.001', -2)).toBe('0.00001'); + expect(helper.shiftDecimal('5', 2)).toBe('500'); + }); + + test('保留符号;places=0 或非整数只做归一化', () => { + expect(helper.shiftDecimal('-1.5', 1)).toBe('-15'); + expect(helper.shiftDecimal('1.50', 0)).toBe('1.50'); // 不动尾巴零 + expect(helper.shiftDecimal('1.50', 1.5)).toBe('1.50'); + }); + + test('归一化先行:首尾零/符号/科学计数法', () => { + expect(helper.shiftDecimal('007.50', 0)).toBe('7.50'); + expect(helper.shiftDecimal('+7', 0)).toBe('7'); + expect(helper.shiftDecimal('-0.000', 0)).toBe('0.000'); // 负零坍缩为无符号 + expect(helper.shiftDecimal('1e-7', 0)).toBe('0.0000001'); + expect(helper.shiftDecimal('1.23e5', 0)).toBe('123000'); + }); + + test('非法输入返回 null,空白串返回空串', () => { + expect(helper.shiftDecimal('abc', 2)).toBeNull(); + expect(helper.shiftDecimal('1.2.3', 2)).toBeNull(); + expect(helper.shiftDecimal('', 2)).toBe(''); + }); +}); + +describe('roundDecimal:绝对值四舍五入(AwayFromZero),字符串路径无浮点陷阱', () => { + + test('经典浮点陷阱对照:1.005 与 0.1×0.2 场景字符串路径给正确答案', () => { + expect(Number(1.005).toFixed(2)).toBe('1.00'); // 浮点路径错(冻结对照) + expect(helper.roundDecimal('1.005', 2)).toBe('1.01'); + expect(helper.roundDecimal('2.345', 2)).toBe('2.35'); + expect(helper.roundDecimal('2.344', 2)).toBe('2.34'); + }); + + test('负数远离零(与 ExitYtm 约定同向)', () => { + expect(helper.roundDecimal('-2.345', 2)).toBe('-2.35'); + expect(helper.roundDecimal('-2.5', 0)).toBe('-3'); + expect(helper.roundDecimal('-0.00005', 4)).toBe('-0.0001'); + }); + + test('进位链:跨数量级与跨整数位', () => { + expect(helper.roundDecimal('9.99', 1)).toBe('10.0'); + expect(helper.roundDecimal('9.99', 0)).toBe('10'); + expect(helper.roundDecimal('99.999', 2)).toBe('100.00'); + expect(helper.roundDecimal('0.00005', 4)).toBe('0.0001'); + }); + + test('不补零也不主动去零:位数不足原样返回,已有位数保留尾巴零', () => { + expect(helper.roundDecimal('1.2', 4)).toBe('1.2'); // 不足4位不补零(补零是 formatCommon 的职责) + expect(helper.roundDecimal('3.10000', 4)).toBe('3.1000'); // 已到4位:只取整不去零(去零是 format 的职责) + }); + + test('防御姿态:负精度/非法输入原样返回,不做校验', () => { + expect(helper.roundDecimal('2.345', -1)).toBe('2.345'); + expect(helper.roundDecimal('2.345', 1.5)).toBe('2.345'); + expect(helper.roundDecimal('abc', 2)).toBe('abc'); + }); +}); + +describe('multiplyDecimal:字符串精确乘法', () => { + + test('0.1×0.2 精确为 0.02(浮点给 0.020000000000000004)', () => { + expect(helper.multiplyDecimal('0.1', '0.2')).toBe('0.02'); + expect(helper.multiplyDecimal('0.1', '0.1')).toBe('0.01'); + expect(helper.multiplyDecimal('1.1', '1.1')).toBe('1.21'); + }); + + test('符号组合;保留小数标度(1.5×2=3.0 而非 3)', () => { + expect(helper.multiplyDecimal('1.5', '2')).toBe('3.0'); + expect(helper.multiplyDecimal('-1.5', '2')).toBe('-3.0'); + expect(helper.multiplyDecimal('-1.5', '-2')).toBe('3.0'); + expect(helper.multiplyDecimal('-0.03', '-0.02')).toBe('0.0006'); + }); + + test('大整数精确(超出 Number.MAX_SAFE_INTEGER)', () => { + expect(helper.multiplyDecimal('123456789', '987654321')).toBe('121932631112635269'); + }); + + test('非法输入 null;空串按 0 处理', () => { + expect(helper.multiplyDecimal('abc', '1')).toBeNull(); + expect(helper.multiplyDecimal('', '1')).toBe('0'); + }); +}); + +describe('getRule / getInputFormat / roundForSubmit:字段规则解析与覆盖优先级', () => { + test('defaults 字段级规则:债券三字段 vs 股票兜底', () => { + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + expect(helper.getRule('Bond', 'grossPrice')).toEqual({ integerDigits: 6, precision: 9 }); + // 债券顶层没有 integerDigits/precision:字段拼错 → null(后续走 umprice 兜底), + // 而 Stock 顶层有 → 字段拼错回落品种级规则。这个不对称冻结于此。 + expect(helper.getRule('Bond', 'unknownField')).toBeNull(); + expect(helper.getRule('Stock', 'anything')).toEqual({ integerDigits: 7, precision: 2 }); + expect(helper.getRule('NoSuchType', 'yield')).toBeNull(); + }); + + test('main.swapPricePrecision 覆盖优先于 defaults,非法覆盖回落 defaults', () => { + withConfig({ Bond: { yield: { integerDigits: 3, precision: 5 } } }); + expect(helper.getRule('Bond', 'yield')).toEqual({ integerDigits: 3, precision: 5 }); + + withConfig({ Bond: { yield: { integerDigits: 0 } } }); + const bad = helper; + expect(bad.getRule('Bond', 'yield')).toEqual({ integerDigits: 2, precision: 4 }); + + // defaults 里没有的品种,配置了也不认:覆盖只允许白名单内微调 + withConfig({ BrandNewType: { yield: { integerDigits: 3, precision: 5 } } }); + const unknown = helper; + expect(unknown.getRule('BrandNewType', 'yield')).toBeNull(); + }); + + test('getInputFormat 合并 options', () => { + expect(helper.getInputFormat('Bond', 'yield', { negative: true })) + .toEqual({ negative: true, integerDigits: 2, precision: 4 }); + expect(helper.getInputFormat('NoSuchType', 'yield', { negative: true })) + .toEqual({ negative: true }); + }); + + test('roundForSubmit:按规则精度+offset 取整;空值/无规则原样返回', () => { + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2)).toBe('3.141593'); + expect(helper.roundForSubmit('', 'Bond', 'yield')).toBe(''); + expect(helper.roundForSubmit('1.2', 'NoSuchType', 'yield')).toBe('1.2'); + expect(helper.roundForSubmit('3.14159265', 'Bond', 'yield', 2.5)).toBe('3.1416'); // 非整数offset忽略 + }); +}); + +describe('format:字段级展示态(取整+去尾巴零)', () => { + test('按字段规则取整并去尾零', () => { + expect(helper.format('3.14159', 'Bond', 'yield')).toBe('3.1416'); + expect(helper.format('3.10000', 'Bond', 'yield')).toBe('3.1'); + expect(helper.format('99.5', 'Stock', 'whatever')).toBe('99.5'); + expect(helper.format('', 'Bond', 'yield')).toBe(''); + }); + + test('无规则时回落 otcformat.trading.umprice', () => { + withConfig(undefined, { + otcformat: { trading: { umprice: function (v) { return 'UM:' + v; } } } + }); + expect(helper.format('1.23', 'NoSuchType', 'yield')).toBe('UM:1.23'); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindBondCalc.test.js b/YLErpWeb/fe-tests/unwindBondCalc.test.js new file mode 100644 index 00000000..2937f9a2 --- /dev/null +++ b/YLErpWeb/fe-tests/unwindBondCalc.test.js @@ -0,0 +1,145 @@ +/** + * unwindBondCalc.test.js — 平仓页 DP↔YD 两字段互算纯函数(EQD-6953) + * ============================================================================ + * 冻结三条与录入页不同、极易被"顺手统一"改坏的约定: + * 1. 发计算器的价格是【展示态直传】,不 ×100(录入页是存储态 ×100); + * 2. ExitYtm 保留 4 位、四舍五入(AwayFromZero)——结算确认书导出固定 4 位不去零, + * 界面精度不得低于导出要求; + * 3. 失败只清【对方】字段(平仓页无净价列,仅 DP/YD 两字段)。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindBondCalc + */ +const UnwindBondCalc = require('../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js'); + +const CALC = { cleanPrice: 98.5, dirtyPrice: 99.5, ytm: 6.3721 }; + +describe('getCalcRequest:展示态直传,不做录入页的 ×100 换算', () => { + test('DP 回车:price 原样 99.5(若误加 ×100 会变成 9950),priceType=DP,估值日=平仓日', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', '2026-08-20'); + expect(req.price).toBe(99.5); + expect(req.priceType).toBe('DP'); + expect(req.targetDate).toBe('2026-08-20'); + expect(req.underlyingCode).toBe('240004.IB'); + expect(req.source).toBe('unwind'); + }); + + test('YD 回车:收益率展示态直传(6.3721 而非 0.063721)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 6.3721, 'YD', '2026-08-20'); + expect(req.price).toBe(6.3721); + expect(req.priceType).toBe('YD'); + }); + + test('估值日为空时 targetDate 为 null(兜底,调用方应先校验)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', null); + expect(req.targetDate).toBeNull(); + }); +}); + +describe('roundExitYtm:4 位小数、四舍五入(远离零)', () => { + test('第 5 位进位', () => { + expect(UnwindBondCalc.roundExitYtm(6.37215)).toBe(6.3722); + }); + test('第 5 位舍去', () => { + expect(UnwindBondCalc.roundExitYtm(6.37214)).toBe(6.3721); + }); + test('负收益率同样远离零(负利率债)', () => { + expect(UnwindBondCalc.roundExitYtm(-0.00005)).toBe(-0.0001); + expect(UnwindBondCalc.roundExitYtm(-1.23445)).toBe(-1.2345); + }); +}); + +describe('applyCalcResult:源字段保持、仅回写对方字段', () => { + test('DP 为源:TradingAmountAvg 保持手输值,ExitYtm 被反算覆盖(4位)', () => { + const state = { TradingAmountAvg: 100.123456, ExitYtm: null }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.37215 }, 'DP'); + expect(state.TradingAmountAvg).toBe(100.123456); // 源:保持 + expect(state.ExitYtm).toBe(6.3722); // 对方:反算+4位取整 + }); + + test('YD 为源:ExitYtm 保持手输值,TradingAmountAvg 被反算覆盖(展示态直写)', () => { + const state = { TradingAmountAvg: null, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: 99.5023 }, 'YD'); + expect(state.ExitYtm).toBe(6.3721); // 源:保持 + expect(state.TradingAmountAvg).toBe(99.5023); // 对方:直写(不 ÷100) + }); + + test('计算器未返回对方值时不覆盖(保留手工输入)', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3 }; + UnwindBondCalc.applyCalcResult(state, { ytm: null }, 'DP'); + expect(state.ExitYtm).toBe(6.3); + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: undefined }, 'YD'); + expect(state.TradingAmountAvg).toBe(100.5); + }); + + test('回写值与现值相同(<1e-9)时不写,避免光标跳动', () => { + const state = { TradingAmountAvg: 100, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.3721 }, 'DP'); + expect(state.ExitYtm).toBe(6.3721); + }); +}); + +describe('applyCalcFailure:保留源字段、只清对方字段、标识全清', () => { + test('DP 为源失败:ExitYtm 清空、全价保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: true, YD: false } }; + UnwindBondCalc.applyCalcFailure(state, 'DP'); + expect(state.TradingAmountAvg).toBe(100.5); + expect(state.ExitYtm).toBeNull(); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('YD 为源失败:TradingAmountAvg 清空、收益率保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'YD' }; + UnwindBondCalc.applyCalcFailure(state, 'YD'); + expect(state.ExitYtm).toBe(6.3); + expect(state.TradingAmountAvg).toBeNull(); + }); + + test('driver 非法(null):仅清标识、不动任何数值(setValueDate 清陈旧标识场景的防误清守卫)', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: false, YD: true } }; + UnwindBondCalc.applyCalcFailure(state, null); + expect(state.TradingAmountAvg).toBe(100.5); // 绝不能被清 + expect(state.ExitYtm).toBe(6.3); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + }); +}); + +describe('标识状态机(交互约定2/3,对齐录入页)', () => { + test('成功:源字段标源、对方标 AUTO、REV 清空', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: null }; + UnwindBondCalc.applyCalcSuccess(state, 'DP'); + expect(state.bondDriverType).toBe('DP'); + expect(state.bondAuto).toEqual({ DP: false, YD: true }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('手动编辑未回车:清 源/AUTO、本字段累计 REV、不清对方 REV', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondRev: { DP: true, YD: false } }; + UnwindBondCalc.applyManualEdit(state, 'YD'); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: true, YD: true }); // 累计:DP 的 REV 保留 + }); + + test('bondRev 缺失时自动初始化', () => { + const state = {}; + UnwindBondCalc.applyManualEdit(state, 'DP'); + expect(state.bondRev).toEqual({ DP: true, YD: false }); + }); +}); + +describe('FIELDS 映射与 hasValue 守卫', () => { + test('字段映射', () => { + expect(UnwindBondCalc.FIELDS).toEqual({ DP: 'TradingAmountAvg', YD: 'ExitYtm' }); + }); + test('hasValue:空串/null/undefined/NaN 为 false,0 为 true', () => { + expect(UnwindBondCalc.hasValue('')).toBe(false); + expect(UnwindBondCalc.hasValue(null)).toBe(false); + expect(UnwindBondCalc.hasValue(undefined)).toBe(false); + expect(UnwindBondCalc.hasValue(NaN)).toBe(false); + expect(UnwindBondCalc.hasValue(0)).toBe(true); + expect(UnwindBondCalc.hasValue('6.37')).toBe(true); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindLegSign.test.js b/YLErpWeb/fe-tests/unwindLegSign.test.js new file mode 100644 index 00000000..3258511c --- /dev/null +++ b/YLErpWeb/fe-tests/unwindLegSign.test.js @@ -0,0 +1,47 @@ +/** + * unwindLegSign.test.js — 平仓页收付方向符号映射(隐式约定显式化) + * ============================================================================ + * 冻结的领域事实(2026-08-21 业务确认): + * 利息腿(融资成本)与保证金腿(返息/返还本金)方向【必须相反】—— + * 利息是买方持有标的向交易商融资的成本(买方付出去的钱); + * 保证金是客户自己交的抵押金,返息/返还是把客户自己的钱退回来。 + * 两者对同一 InterestDirection 枚举符号互为镜像,这是业务事实而非笔误, + * 任何人"顺手统一"这两个符号都会翻转保证金返还方向(资损级 bug)。 + * + * 另冻结:PayDirection==1 → +1、PositionType==1(多头) → +1 的浮动端/多空符号。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindLegSign + */ +const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'); + +describe('收付方向符号:各枚举映射', () => { + test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => { + expect(UnwindLegSign.interestPnlSign(1)).toBe(1); + expect(UnwindLegSign.interestPnlSign(0)).toBe(-1); + expect(UnwindLegSign.interestPnlSign(null)).toBe(-1); + }); + + test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => { + expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1); + expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1); + expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1); + }); + + test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => { + expect(UnwindLegSign.payDirectionSign(1)).toBe(1); + expect(UnwindLegSign.payDirectionSign(0)).toBe(-1); + expect(UnwindLegSign.positionTypeSign(1)).toBe(1); + expect(UnwindLegSign.positionTypeSign(0)).toBe(-1); + }); +}); + +describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => { + test('同一 InterestDirection 下两符号之和恒为 0', () => { + [1, 0, null, undefined, 2].forEach(dir => { + expect( + UnwindLegSign.interestPnlSign(dir) + + UnwindLegSign.marginRebatePrincipalSign(dir) + ).toBe(0); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index f0774f96..488a0c3b 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -66,6 +66,8 @@ function loadUnwindHelpers() { roundHalfAwayFromZero(value) { return value; }, calcCloseQtyByOriginalPercent() { return 0; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 90106724..d912b401 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -352,6 +352,13 @@ return "债券计算收益率量级异常(" + yd + "),已保留手工输入;" + "请检查估值日/价格输入或联系管理员核对债券计算服务"; } + // 疑似到期/无剩余现金流:估值日过到期日后 jquantlib 对空现金流求解得 ytm=0、净/全价均为面值100 + // (三者同时成立在现实在市券中几乎不可能,UAT 060203.IB 实证)。宁可不回写并提示真实原因, + // 也不用退化值覆盖手工输入。与后端 BondCalcHepler.IsMaturedDegenerate 同口径,勿单边改动判定条件。 + if (yd === 0 && cp === 100 && dp === 100) { + return "该债券疑似已到期或无剩余现金流(收益率=0、净价/全价均为面值100),无法反算," + + "请核对债券到期日或手工填写净价/全价/收益率"; + } return null; } diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js index 6f82f664..e329f789 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmList.js @@ -26,6 +26,11 @@ function showToolName(cellValue, options, rowObject) { //var disabled = pageData.canGenerate == true ? "" : "disabled"; //html += "" // .template(rowObject.swap_flow_event.EncryptId, rowObject.swap_flow_event.PayDate, "修改支付日期", disabled); + if (rowObject.ContractDocUrl && pageData.canSendEmail) { + html += + "" + .template(rowObject.swap_flow_event.EncryptId, rowObject.trade.StructureType, "发送Email"); + } return html; } @@ -167,21 +172,32 @@ function SendEmailEitherReport() { main.message("请选择普通交易"); return; } - var url = "/trs_hub_api/swap/email/settle/batchSend"; - var postData = { swapFlowEventIds: selIds } + // EQD-5320: 改走本系统后端代理(/swaptrade2/BatchSendSettleEmail → BondOmsInterface_BaseUrl → bond-oms), + // 与债券计算器同一条服务端出口;原直连 /trs_hub_api 反向代理在未配 nginx 的环境 404 + var url = "/swaptrade2/BatchSendSettleEmail"; + var postData = { swapFlowEventIds: selIds.join(',') } $.ajax({ type: "post", url: url, - data: JSON.stringify(postData), + data: postData, dataType: "json", - contentType:"application/json", success: function (res) { - if (res.success) { - main.message("发送成功"); + // 兜底非标准返回结构,绝不让点击"无反应" + if (res && res.success) { + main.message(res.msg || ("发送成功(共" + selIds.length + "笔)")); + SearchClick(); // 刷新列表以反映发送状态 } else { - main.message(res.message); + var msg = (res && (res.msg || res.message)) || "发送失败:服务返回异常结构"; + main.message(msg); + console.error("[批量发送确认书] 业务失败:", res); } }, + error: function (xhr, textStatus, errThrown) { + // 请求层失败:超时/返回非JSON(如被登录页重定向) + var detail = "HTTP " + xhr.status + " " + (textStatus || "") + (errThrown ? " " + errThrown : ""); + main.message("发送请求失败(" + detail + "):请检查服务是否可用,详情见控制台与网络面板"); + console.error("[批量发送确认书] 请求失败:", detail, xhr); + }, beforeSend(jqXHR) { main.waitMe(true); }, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js new file mode 100644 index 00000000..afcab928 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/tradeEndConfirmSendEmail.js @@ -0,0 +1,40 @@ +// 结算确认书:单行发送邮件(复用 /swaptrade2/BatchSendSettleEmail 服务端代理 → bond-oms Java 发信) +// 单独成文件,降低与 tradeEndConfirmList.js 的并发修改冲突;核心发送逻辑与网格渲染解耦,便于单测。 + +// 纯函数:根据加密平仓流水ID构造请求负载(不依赖 $.ajax / DOM),便于单元验证。 +function buildSettleEmailSendPayload(encryptId) { + return { url: "/swaptrade2/BatchSendSettleEmail", swapFlowEventIds: encryptId }; +} + +// 单行发送结算确认书邮件;encryptId 为空、或与批量 SendEmailEitherReport 一致地跳过"多空组合"时早退。 +function sendSettleEmail(encryptId, structureType) { + if (!encryptId) { + main.message("缺少平仓流水ID"); + return; + } + if (structureType === "多空组合") { + main.message("多空组合不支持发送结算确认书"); + return; + } + var payload = buildSettleEmailSendPayload(encryptId); + $.ajax({ + type: "post", + url: payload.url, + data: { swapFlowEventIds: payload.swapFlowEventIds }, + dataType: "json", + success: function (res) { + if (res && res.success) { + main.message(res.msg || "发送成功"); + SearchClick(); + } else { + main.message((res && (res.msg || res.message)) || "发送失败"); + } + }, + error: function (xhr) { + main.message("发送请求失败(HTTP " + xhr.status + "),请检查服务"); + console.error("[单行发送结算确认书] 请求失败:", xhr); + }, + beforeSend: function () { main.waitMe(true); }, + complete: function () { main.waitMe(false); } + }); +} diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js new file mode 100644 index 00000000..4c52f009 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js @@ -0,0 +1,132 @@ +/** + * unwindBondCalc.js — 平仓页 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 两字段互算(EQD-6953) + * ============================================================================ + * 与录入页(swapTradeEdit.js + swapCalc.js 债券三字段互算)的关键差异,【勿"顺手统一"】: + * 1. 单位状态:录入页三字段(CP/DP/YD)存【存储态小数】(0.995),发计算器前 ×100、回写 ÷100 + * (bondPriceToCalc/bondCalcPriceToStorage);平仓页 TradingAmountAvg 在 initDeal 已转 + * 【展示态百分数】(99.5),ExitYtm 亦定义为展示态(6.3721)——发计算器不换算、回写不换算。 + * 提交时的存储态转换仍由既有 getStorageDeliveryPrice()/dataFormat() 负责。 + * 2. 字段集:只有 DP(TradingAmountAvg)/YD(ExitYtm) 两个字段(平仓页无净价列), + * 失败只清对方字段(录入页 applyBondCalcFailure 清另外两个,字段名也不同,故不复用)。 + * 3. 估值日:用平仓日 deal.ValueDate(结算语义);录入页用互换起始日 StartDate。 + * 4. 精度:ExitYtm 保留 4 位小数、四舍五入(AwayFromZero)——结算确认书导出要求 + * 固定 4 位不去零(ToString("0.0000")),界面精度不得低于该要求。 + * + * 浏览器:挂 window.UnwindBondCalc(需在 swapCalc.js 之后、unwindSwapTrade.js 之前加载)。 + * Node:module.exports(UMD),供 fe-tests/unwindBondCalc.test.js 使用。 + * 命名遵《互换价格字段命名规范决策文档》:平仓/了结时点 = Exit(非 End/Close/Final)。 + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(require('./swapCalc.js')); + } else { + root.UnwindBondCalc = factory(root.SwapCalc); + } +})(typeof self !== 'undefined' ? self : this, function (SwapCalc) { + 'use strict'; + + // driver(回车的"源"字段) → 平仓页浮动腿(floatPosition)字段名 + var FIELDS = { DP: 'TradingAmountAvg', YD: 'ExitYtm' }; + + function hasValue(v) { + return v !== null && v !== undefined && v !== '' && !isNaN(v); + } + + /** + * 构造 /Bond/CalcBond 请求体。 + * ⚠️ price 必须传【展示态百分数】直传(99.5),不做录入页那种 ×100—— + * 平仓页模型里两个字段本就是展示态。此约定由 fe-tests 冻结,防止后人误加换算。 + * source='unwind':BondController 记进日志,便于区分录入页/平仓页来源定位问题。 + */ + function getCalcRequest(underlyingCode, price, driver, valueDate) { + return { + underlyingCode: underlyingCode, + price: Number(price), + priceType: driver, + targetDate: valueDate || null, + source: 'unwind' + }; + } + + /** + * ExitYtm 统一取整:4 位、四舍五入(远离零),对齐后端 decimal ToString("0.0000")。 + * 非数值(null/undefined/NaN)返回 null——供 write 跳过,绝不能把 null 取整成 0 回写 + * (否则计算器未返回 ytm 时会清掉用户手工输入)。 + */ + function roundExitYtm(value) { + if (value === null || value === undefined || isNaN(value)) return null; + return SwapCalc.roundHalfAwayFromZero(value, 4); + } + + /** + * 计算成功回写(交互约定1+2):源字段保持用户手输值,仅回写对方字段(展示态直写)。 + * - driver='DP'(全价回车)→ 回写 ExitYtm = ytm 取整 4 位;计算器未返回 ytm 则不覆盖。 + * - driver='YD'(收益率回车)→ 回写 TradingAmountAvg = dirtyPrice;未返回则不覆盖。 + * ⚠️ 调用方回写 TradingAmountAvg 后须显式重算平仓盈亏(calcFloatClosePnl)。 + * 纯函数,jest 可直接测。 + */ + function applyCalcResult(state, calc, driver) { + function write(field, value) { + if (value === undefined || value === null) return; // 计算器未返回则不覆盖 + var cur = state[field]; + if (typeof cur === 'number' && Math.abs(cur - value) < 1e-9) return; // 无变化不写 + state[field] = value; + } + if (driver === 'DP') { + write('ExitYtm', roundExitYtm(calc && calc.ytm)); + } else if (driver === 'YD') { + write('TradingAmountAvg', calc && calc.dirtyPrice); + } + return state; + } + + /** + * 计算失败落地(交互约定2):保留源字段值(用户可就地改),清对方字段、清全部标识。 + * 与录入页差异:只清"对方"一个字段(录入页清另外两个)。 + * driver 非法(null/未知)时仅清标识、不动任何数值——防调用方误清源字段 + * (如 setValueDate 只想清陈旧标识时误传 null,会把 TradingAmountAvg 清掉)。 + */ + function applyCalcFailure(state, driver) { + if (driver === 'DP' || driver === 'YD') { + var other = driver === 'DP' ? 'YD' : 'DP'; + state[FIELDS[other]] = null; + } + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** 计算成功后的标识(交互约定2):源字段标"源",对方标"AUTO"。 */ + function applyCalcSuccess(state, driver) { + state.bondDriverType = driver; + state.bondAuto = { DP: driver !== 'DP', YD: driver !== 'YD' }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** + * 编辑未回车(失焦/按键)的标识(交互约定3):清 源/AUTO,仅本字段累计标"REV", + * 不联动对方字段(允许计算有问题时手工覆盖 ExitYtm)。同录入页:累计 REV 不互清。 + */ + function applyManualEdit(state, type) { + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + if (!state.bondRev || typeof state.bondRev !== 'object') { + state.bondRev = { DP: false, YD: false }; + } + if (type) state.bondRev[type] = true; + return state; + } + + return { + FIELDS: FIELDS, + hasValue: hasValue, + getCalcRequest: getCalcRequest, + roundExitYtm: roundExitYtm, + applyCalcResult: applyCalcResult, + applyCalcFailure: applyCalcFailure, + applyCalcSuccess: applyCalcSuccess, + applyManualEdit: applyManualEdit + }; +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js new file mode 100644 index 00000000..a07d62cd --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js @@ -0,0 +1,45 @@ +/** + * unwindLegSign.js — 平仓页收付方向符号(纯函数) + * ============================================================================ + * 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。 + * 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反—— + * 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug): + * 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效): + * 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱), + * InterestDirection==1(收取) → 盈亏记 +1; + * 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来, + * 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。 + * + * 其余两个符号:PayDirection==1(浮动端收取) → +1;PositionType==1(多头) → +1。 + * + * 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Node:module.exports 供 jest。 + */ +var UnwindLegSign = (function () { + 'use strict'; + + // Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支 + function interestPnlSign(interestDirection) { + return Number(interestDirection) === 1 ? 1 : -1; + } + + function marginRebatePrincipalSign(interestDirection) { + return Number(interestDirection) === 1 ? -1 : 1; + } + + function payDirectionSign(payDirection) { + return Number(payDirection) === 1 ? 1 : -1; + } + + function positionTypeSign(positionType) { + return Number(positionType) === 1 ? 1 : -1; + } + + return Object.freeze({ + interestPnlSign: interestPnlSign, + marginRebatePrincipalSign: marginRebatePrincipalSign, + payDirectionSign: payDirectionSign, + positionTypeSign: positionTypeSign + }); +}()); + +if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 1f70d833..346174c4 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -11,6 +11,9 @@ const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true }); +// EQD-6953 期末标的结算收益率(ExitYtm):展示态百分数(如 6.3721),trimTailZeros:false 不去零; +// 精度由 getInputFormat('yield') 规则给 4 位——不得低于结算确认书导出的固定 4 位(0.0000)。 +const inputFormatUnwindExitYtm = Object.freeze({ append: '', negative: true, trimTailZeros: false }); const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 }); const swapPosiFeeCalc = { normalizeFeeType(feeType) { @@ -61,11 +64,11 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, + // EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算 + isBondTRS: false, // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, - ratio: 1, - shortRatio: 1, }, computed: { maxUnwindDate() { @@ -73,10 +76,15 @@ const vue = new Vue({ }, minStartDate() { return this.deal.StartDate; + }, + // EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算 + isBondUnwindLeg() { + return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType); } }, created() { - this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1; + this.isBondTRS = this.deal.StructureType == '普通债券类收益互换'; + this.multiplier = this.isBondTRS ? 100 : 1; this.initDeal(); this.setValueDate(this.deal.ValueDate); }, @@ -115,8 +123,6 @@ const vue = new Vue({ this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; }); - this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; - this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) @@ -151,6 +157,10 @@ const vue = new Vue({ this.floatPosition.TradingAmountAvg, this.floatPosition.UnderlyingInstrumentType, 'grossPrice'); + // EQD-6953 期末结算收益率:提交前统一 4 位四舍五入(幂等;空/非数保持原样不覆盖) + if (UnwindBondCalc.hasValue(this.floatPosition.ExitYtm)) { + this.floatPosition.ExitYtm = UnwindBondCalc.roundExitYtm(this.floatPosition.ExitYtm); + } this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee); this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending); this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn); @@ -174,7 +184,17 @@ const vue = new Vue({ this.deal.UnwindDate = e; this.floatPosition.UnwindDate = e; } + // EQD-6953 估值日(平仓日)变了,已算出的期末结算收益率随之失效:清值+清标识,待用户重新回车计算 + // (applyManualEdit(null) 只清 源/AUTO/REV 不动任何数值;勿用 applyCalcFailure(null)—— + // 它对 null driver 会误清 TradingAmountAvg) + if (this.isBondUnwindLeg && this.floatPosition.ExitYtm != null) { + this.floatPosition.ExitYtm = null; + UnwindBondCalc.applyManualEdit(this.floatPosition, null); + this.syncUnwindBondFlags(); + } if (!isUseApproval) { + // 新增:刷新持仓基线(处理公司行为除权) + this.refreshUnwindBaseline(); this.getInterestList(); //this.refreshUnderlyingPrice(); } else { @@ -299,10 +319,111 @@ const vue = new Vue({ thisObj.calcFloatClosePnl(); }); }, + //================================================================================== + // EQD-6953 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 互算 + // 交互对齐录入页(swapTradeEdit.js):回车=以该字段为源调 /Bond/CalcBond 反算对方; + // 失败=保留源字段、清对方、标识全清;手填未回车=REV 不联动(允许计算有问题时手工覆盖)。 + // 关键差异见 unwindBondCalc.js 头注:平仓页两字段均为展示态(不 ×100/÷100),估值日=平仓日 ValueDate。 + //================================================================================== + getYieldInputFormat() { + return swapPricePrecision.getInputFormat( + this.floatPosition && this.floatPosition.UnderlyingInstrumentType, + 'yield', + inputFormatUnwindExitYtm); + }, + //期末交割全价的 input(失焦/回车都会触发):保持既有盈亏联动;债券腿按交互约定3标 REV。 + onEndDeliveryPriceInput() { + this.changeUnderlyingPrice(); + if (this.isBondUnwindLeg) { + UnwindBondCalc.applyManualEdit(this.floatPosition, 'DP'); + this.syncUnwindBondFlags(); + } + }, + //收益率字段编辑(失焦/回车触发,组件内部回车时 enter 随后修正标识):约定3 标 REV 不联动。 + onEndBondPriceEdit(type) { + if (!this.isBondUnwindLeg) return; + UnwindBondCalc.applyManualEdit(this.floatPosition, type); + this.syncUnwindBondFlags(); + }, + //按键实时标 REV(组件 input 事件只在失焦/回车触发,逐键输入期间靠 keydown 清 源/AUTO)。 + //回车/修饰键/导航键/功能键不改标识(SwapCalc.isBondPriceValueKey 白名单)。 + onEndBondPriceKeydown(type, event) { + if (!this.isBondUnwindLeg) return; + if (!SwapCalc.isBondPriceValueKey(event)) return; + UnwindBondCalc.applyManualEdit(this.floatPosition, type); + this.syncUnwindBondFlags(); + }, + //回车=以该字段为源调计算器反算对方字段。 + onEndBondPriceEnter(type) { // type: 'DP'期末交割全价 / 'YD'期末结算收益率 + if (!this.isBondUnwindLeg) return; + if (!this.floatPosition.UnderlyingCode) { main.message("请先选择债券标的"); return; } + var price = type === 'DP' ? this.floatPosition.TradingAmountAvg : this.floatPosition.ExitYtm; + if (!UnwindBondCalc.hasValue(price)) return; // 空/非数:不触发计算器 + this.calcUnwindBond(type); + }, + //以 driver(回车字段)为源调 /Bond/CalcBond。成败路由见 base/main.js __post:业务错误走 reject, + //失败落地必须在 .fail(录入页曾把失败处理只写 .done 导致静默失效,勿重蹈)。 + calcUnwindBond(driver) { + if (!this.isBondUnwindLeg) return; + var fp = this.floatPosition; + fp.bondDriverType = driver; // 先定源,回写时 applyCalcResult 会跳过该字段 + var price = driver === 'DP' ? fp.TradingAmountAvg : fp.ExitYtm; + if (!UnwindBondCalc.hasValue(price)) { + UnwindBondCalc.applyCalcFailure(fp, driver); + this.syncUnwindBondFlags(); + return; + } + if (!this.deal.ValueDate) { + main.message("请先填写平仓日期(作为债券计算器估值日)"); + UnwindBondCalc.applyCalcFailure(fp, driver); + this.syncUnwindBondFlags(); + return; + } + var req = UnwindBondCalc.getCalcRequest(fp.UnderlyingCode, price, driver, this.deal.ValueDate); + otcDebug.log('[unwindBondCalc] enter driver=' + driver + ' req=' + JSON.stringify(req)); + var self = this; + main.post('/Bond/CalcBond', req, { alertFn: main.message }).done(function (resp) { + if (!resp || !resp.obj) { // 网络错误/响应异常:框架已提示,按约定2清对方字段+标识 + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + return; + } + var err = SwapCalc.getBondCalcErrorMessage(resp.obj); + if (err) { // 业务错误(债券不存在/信息不全/参数非法/值域离谱):toast 去重提示,不回写 + if (SwapCalc.shouldShowBondErr(fp, err)) main.message(err); + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + otcDebug.log('[unwindBondCalc] calc-error driver=' + driver + ' msg=' + err); + return; + } + fp._lastBondErr = null; + UnwindBondCalc.applyCalcResult(fp, resp.obj, driver); + UnwindBondCalc.applyCalcSuccess(fp, driver); + self.syncUnwindBondFlags(); + // YD 为源时 TradingAmountAvg 被反算回填,直接赋值不触发 input 事件,须显式重算盈亏 + if (driver === 'YD') self.calcFloatClosePnl(); + otcDebug.log('[unwindBondCalc] success driver=' + driver + + ' ExitYtm=' + fp.ExitYtm + ' TradingAmountAvg=' + fp.TradingAmountAvg + + ' auto=' + JSON.stringify(fp.bondAuto)); + }).fail(function () { + UnwindBondCalc.applyCalcFailure(fp, driver); + self.syncUnwindBondFlags(); + otcDebug.log('[unwindBondCalc] post-fail/reject driver=' + driver); + }); + }, + //确保 bondDriverType/bondAuto/bondRev 的变更触发 Vue 2 响应式更新(同录入页 syncBondFlags: + //这些属性不在后端模型里,$set + 全新对象引用 + $forceUpdate 兜底,缺一视图不刷新)。 + syncUnwindBondFlags() { + var fp = this.floatPosition; + this.$set(fp, 'bondDriverType', fp.bondDriverType === undefined ? null : fp.bondDriverType); + this.$set(fp, 'bondAuto', { DP: !!(fp.bondAuto && fp.bondAuto.DP), YD: !!(fp.bondAuto && fp.bondAuto.YD) }); + this.$set(fp, 'bondRev', { DP: !!(fp.bondRev && fp.bondRev.DP), YD: !!(fp.bondRev && fp.bondRev.YD) }); + this.$forceUpdate(); + }, calcFloatClosePnl() {//计算浮动端平仓盈亏 var thisObj = this; - let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; - let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection); + let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let deliveryPrice = thisObj.getStorageDeliveryPrice(); @@ -317,7 +438,7 @@ const vue = new Vue({ this.calcFloatClosePnl(); }, changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; + let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection); item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio); this.calcCloseAmount(); }, @@ -327,8 +448,8 @@ const vue = new Vue({ // this.calcCloseAmount(); //}, calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1; - let ratio = this.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection); + let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType); let thisObj = this; let pnl = parseFloat(this.floatPosition.FloatPnlSum); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); @@ -345,14 +466,14 @@ const vue = new Vue({ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; } this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ let interestAmount = parseFloat(x.InterestClosePnL); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; }); this.marginList.forEach(x => { let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; + // 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一) + let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; @@ -365,6 +486,57 @@ const vue = new Vue({ thisObj.deal.SwapMarginRebatePnl = formatSwapAmount(thisObj.deal.SwapMarginRebatePnl); thisObj.deal.SwapMarginAmount = formatSwapAmount(thisObj.deal.SwapMarginAmount); }, + refreshUnwindBaseline() {//刷新持仓基线(处理公司行为除权) + var thisObj = this; + var postData = { + tradeId: thisObj.deal.SwapTradeId, + valueDate: thisObj.deal.ValueDate + }; + main.post("/swaptrade2/RefreshUnwindBaseline", postData, { async: true }).done(function (resp) { + if (resp.success) { + var data = resp.obj; + // 更新持仓数量和名义本金 + thisObj.deal.PositionQty = data.PositionQty; + thisObj.deal.PositionQty2 = data.PositionQty; // 新增:同时更新 PositionQty2(界面显示用) + thisObj.deal.PosiNotionalValue = data.PosiNotionalValue; + + // 如果是全平,更新平仓数量和名义本金 + if (thisObj.deal.CloseMethod == 1) { // 全部平仓 + thisObj.deal.CloseQty = data.CloseQty; + thisObj.deal.CloseNotionalValue = data.CloseNotionalValue; + } + + // 更新浮动腿的期初价格和数量 + if (thisObj.floatPosition) { + thisObj.floatPosition.PosiGrossPrice = data.PosiGrossPrice; + thisObj.floatPosition.PosiNetPrice = data.PosiNetPrice; + thisObj.initPosiNetPrice = data.PosiGrossPrice; + // 更新浮动腿的持仓数量(底部表格显示用) + thisObj.floatPosition.PositionQty = data.PositionQty; + } + + // 重新计算最多可平比例 + thisObj.oriClosePercent = (thisObj.deal.NotionalValue && thisObj.deal.PosiNotionalValue) + ? thisObj.deal.PosiNotionalValue / thisObj.deal.NotionalValue : 1; + + // 如果是全平,更新平仓比例 + if (thisObj.deal.CloseMethod == 1) { + thisObj.deal.ClosePercent = thisObj.oriClosePercent; + } + + // 强制 Vue 更新界面 + thisObj.$forceUpdate(); + + // 可选:显示提示信息 + if (data.IsRestored && window.otcDebug) { + console.log('[公司行为] 已根据除权后的EOD基线刷新持仓数据', { + PositionQty: data.PositionQty, + PosiGrossPrice: data.PosiGrossPrice + }); + } + } + }); + }, getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; // closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算 diff --git a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js index 68c4b3a8..08a3850d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js +++ b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js @@ -308,6 +308,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } var item2 = { @@ -321,6 +322,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } if (selectType == "2") { //交易 @@ -469,6 +471,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } var childNodes = thisObj.openItems.filter(x => x.Index > index); @@ -494,6 +497,7 @@ var app = new Vue({ approvalGroupId: 0, conditionConfig: '', triggerCondition: '', + isOaApproval: false, _trigger: { tokens: [] } } thisObj.getRuleType(); @@ -956,7 +960,8 @@ var app = new Vue({ approvalGroupId: value.approvalGroupId, approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, - triggerCondition: value.triggerCondition + triggerCondition: value.triggerCondition, + isOaApproval: value.isOaApproval === true }); }); @@ -972,7 +977,8 @@ var app = new Vue({ approvalGroupId: value.approvalGroupId, approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, - triggerCondition: value.triggerCondition + triggerCondition: value.triggerCondition, + isOaApproval: value.isOaApproval === true }); }); // 需求②:了结/平仓/行权审批流程 @@ -990,6 +996,7 @@ var app = new Vue({ approvalCondition: value.approvalCondition, conditionConfig: value.conditionConfig, triggerCondition: value.triggerCondition + ,isOaApproval: value.isOaApproval === true }); }); } diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js index 21bd8878..9904457a 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeApproval.js @@ -79,6 +79,13 @@ var colModelGrid = [ width: 60, align: 'left' }, + { + name: 'OaRemark', + label: 'OA备注', + index: 'OaRemark', + width: 80, + align: 'left' + }, { name: 'id', label: 'id', @@ -1038,4 +1045,4 @@ function ProcessOptDateFilter(cellValue, options, rowObject) { } $("#DateFromTradeDate").parent().addClass("trade-wrap"); -$("#DateFromOptDate").parent().addClass("time-wrap"); \ No newline at end of file +$("#DateFromOptDate").parent().addClass("time-wrap"); diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index 8039367f..65c9cbb2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -18,8 +18,10 @@ function saveInfo(dataId, rowId) { id: dataId, UnderlyingCode: underlyingCode, ExDividendDate: $("#" + rowId + "_ExDividendDate").val(), + EffectiveDate: $("#" + rowId + "_EffectiveDate").val(), GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(), GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(), + Split: $("#" + rowId + "_Split").val(), ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(), RationedSharesAmount: $("#" + rowId + "_RationedSharesAmount").val(), RationedSharesPrice: $("#" + rowId + "_RationedSharesPrice").val() @@ -76,8 +78,10 @@ function gridComplete(obj) { id: 0, UnderlyingCode: underlyingCode, ExDividendDate: null, + EffectiveDate: null, GiveCashAmount: 0.0, GiveShareAmount: 0, + Split: 1, RationedSharesAmount: 0, RationedSharesPrice: 0, OptName: null, @@ -107,10 +111,14 @@ var colModelGrid = [{ name: 'EncryptId', label: 'EncryptId', index: 'EncryptId', width: 0, hidden: true }, { name: 'ExDividendDate', label: '股权登记日', index: 'ExDividendDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, +}, { + name: 'EffectiveDate', label: '真实除权日', index: 'EffectiveDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, }, { name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { name: 'GiveShareAmount', label: '送股股数(10股)', index: 'GiveShareAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, +}, { + name: 'Split', label: '拆/合股倍数', index: 'Split', width: 100, formatter: { number: { decimalPlaces: 6, defaultValue: '1' } }, editable: true, editrules: { number: true }, }, { name: 'RationedSharesAmount', label: '配股股数(10股)', index: 'RationedSharesAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js index 7a8de500..a5a2724d 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingedit.js @@ -13,7 +13,8 @@ }()); -const consSelect = ['MarketCode', 'UnderlyingInstrumentType', 'UnderlyingState', 'UnderlyingStatus', 'UpDownLimitType']; + +const consSelect = ['MarketCode', 'UnderlyingInstrumentType', 'UnderlyingState', 'UnderlyingStatus', 'UpDownLimitType', 'EtfSubType']; var autoUpDownLimit, autoVariety; @@ -102,11 +103,15 @@ $(function () { case "OtherBonds": classType = "Bonds"; break; + case "Fund": + // 重点功能:资产类型为基金及基金专户时,显示所有 Fund 专属字段。 + classType = "Fund"; + break; default: classType = type; break; } - $('#editForm').removeClass("form-None form-Stock form-CommodityFutures form-CommoditySpot form-Bonds").addClass("form-" + classType); + $('#editForm').removeClass("form-None form-Stock form-CommodityFutures form-CommoditySpot form-Bonds form-Fund").addClass("form-" + classType); }).trigger('change'); }); @@ -130,6 +135,11 @@ function saveData() { return main.alert("资产品种类型 必须填写!"); } + // 重点功能:ETF 子类只对基金及基金专户显示并必填,先在前端阻止无效提交。 + if (data.UnderlyingInstrumentType === "Fund" && !data.EtfSubType) { + return main.alert("ETF 子类 必须填写!"); + } + if (data.UnderlyingInstrumentType === "CommodityFutures" && !data.MaturityDate) { return main.alert("到期日 必须填写!"); } @@ -144,4 +154,4 @@ function saveData() { main.parentReloadData(); window.location.href = "/underlying_manager/underlying_managerView?enid=" + resp.obj.EncryptId; }); -} \ No newline at end of file +}