diff --git a/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs b/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs index 5e597013..6a10b28a 100644 --- a/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs +++ b/Framework/YLErp.Core/Configuration/Abstract/IErpConfig.cs @@ -596,6 +596,11 @@ namespace YLErp.Configuration /// int ExternalAPIForCustomCalcTimeOut { get; set; } + /// + /// 收益互换 OA 移动审批状态查询间隔(秒)。 + /// + int TradeApprovalOaSyncIntervalSeconds { get; set; } + bool IsForwardTradeToBuy { get; set; } /// /// 是否需要计算实时预警 diff --git a/Framework/YLErp.Core/Configuration/ProjectSettings.cs b/Framework/YLErp.Core/Configuration/ProjectSettings.cs index 7b4fe8ca..102145fd 100644 --- a/Framework/YLErp.Core/Configuration/ProjectSettings.cs +++ b/Framework/YLErp.Core/Configuration/ProjectSettings.cs @@ -702,6 +702,11 @@ namespace YLErp /// public int ExternalAPIForCustomCalcTimeOut { get; set; } + /// + /// 收益互换 OA 移动审批状态查询间隔(秒)。 + /// + public int TradeApprovalOaSyncIntervalSeconds { get; set; } = 30; + public bool IsForwardTradeToBuy { get; set; } /// /// 是否需要实时预警 diff --git a/Framework/YLErp.Core/DBModels/Approvalprocess.cs b/Framework/YLErp.Core/DBModels/Approvalprocess.cs index ee1339a2..5af5e653 100644 --- a/Framework/YLErp.Core/DBModels/Approvalprocess.cs +++ b/Framework/YLErp.Core/DBModels/Approvalprocess.cs @@ -70,5 +70,9 @@ namespace YLErp.DBModels /// [DisplayName("节点触发条件")] public string triggerCondition { get; set; } + + /// 是否在进入该节点时关联国联民生 OA 移动审批。 + public bool isOaApproval { get; set; } + } } diff --git a/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs new file mode 100644 index 00000000..66be6224 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/trade_approval_oa_result.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + /// + /// 衍生品交易审批节点对应的国联民生 OA 流程记录。 + /// + [Table("trade_approval_oa_result")] + public class trade_approval_oa_result : DBModelBaseV6 + { + public int trade_id { get; set; } + public int approval_process_id { get; set; } + public string process_type { get; set; } + public int? applicant_id { get; set; } + public string applicant_login_name { get; set; } + public string oa_fileid { get; set; } + public string status { get; set; } + public string oa_msg { get; set; } + public string request_payload { get; set; } + public string last_response { get; set; } + public DateTime? last_query_time { get; set; } + public bool is_valid { get; set; } + } +} diff --git a/Framework/YLErp.Core/DBModels/underlying_manager.cs b/Framework/YLErp.Core/DBModels/underlying_manager.cs index 2808b5f5..b1187b6e 100644 --- a/Framework/YLErp.Core/DBModels/underlying_manager.cs +++ b/Framework/YLErp.Core/DBModels/underlying_manager.cs @@ -137,6 +137,20 @@ namespace YLErp.DBModels [DisplayName("标的名称")] public string UnderlyingName { set; get; } + /// + /// 基金及基金专户的基金管理人名称 + /// + [Column("investadvisorname")] + [DisplayName("基金管理人")] + public string InvestAdvisorName { get; set; } + + /// + /// 基金及基金专户所属的 ETF 子类,保存“ETF 子类”字典项名称 + /// + [Column("etf_sub_type")] + [DisplayName("ETF 子类")] + public string EtfSubType { get; set; } + /// /// 标的英文名 diff --git a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml index 97770296..b56ab947 100644 --- a/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml +++ b/Framework/YLErp.Resources/Dictionary/db_dictionaries.xml @@ -42,4 +42,17 @@ + + + + + + + + + + + + + diff --git a/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx b/Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/国联民生-收益互换交易确认书-境内模板-【客户看多】-【债券ETF】-清洁版.docx index 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/DocumentGenerator/TradeConfirmationGenerator.cs b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs index a4047271..e89a5ed7 100644 --- a/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs +++ b/Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs @@ -508,7 +508,8 @@ namespace YLErp.Plugins.GuoLian.DocumentGenerator dic["参考标的名义份额"] = swapPosition != null ? ((double)swapPosition.PosiQuantity).ToString("0.##") : "0"; - dic["参考标的基金管理人"] = ""; + + dic["参考标的基金管理人"] = underlying?.InvestAdvisorName ?? ""; var contractTypeId = (Context.GetContractTypes().FirstOrDefault(O => O.ContactType == "交易确认书接收")?.id) ?? 0; // 乙方联系人信息 var clientDuties = Context.GetClientDuties().Where(O => O.ContactTypeIdsInt.Contains(contractTypeId)).ToList(); diff --git a/UnitTestProject/BasicTests/GLMSGreeksHandleServiceTest.cs b/UnitTestProject/BasicTests/GLMSGreeksHandleServiceTest.cs new file mode 100644 index 00000000..f3b570b7 --- /dev/null +++ b/UnitTestProject/BasicTests/GLMSGreeksHandleServiceTest.cs @@ -0,0 +1,51 @@ +using YLErp.Modules.CalculationModule; +using YLErp.Modules.EodModule; + +namespace YLErp.BasicTests +{ + [TestClass] + public class GLMSGreeksHandleServiceTest + { + [TestMethod] + public void Handle_NonRateAsset_CalculatesVega1bpBeforeRateFilter() + { + var service = new GLMSGreeksHandleService(); + var dto = new EodPositionRisksDTO + { + Vega = 2500d + }; + var underlying = new underlying_manager + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock + }; + + service.Handle(dto, underlying); + + Assert.IsNotNull(dto.Vega_1bp); + Assert.AreEqual(0.25d, dto.Vega_1bp.Value, 1e-12); + Assert.IsNull(dto.Vega_r); + Assert.IsNull(dto.Vega_r_1bp); + } + + [TestMethod] + public void Handle_TradeValueResult_NonRateAsset_CalculatesVega1bpBeforeRateFilter() + { + var service = new GLMSGreeksHandleService(); + var result = new TradeValueResult + { + Vega = 2500d + }; + var underlying = new underlying_manager + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock + }; + + service.Handle(new trade(), result, underlying); + + Assert.IsNotNull(result.Vega_1bp); + Assert.AreEqual(0.25d, result.Vega_1bp.Value, 1e-12); + Assert.IsNull(result.Vega_r); + Assert.IsNull(result.Vega_r_1bp); + } + } +} 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/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/EodPositionRisks.cs b/YLErpDAL/Model/EodPositionRisks.cs index 3669e15c..0bf11bcf 100644 --- a/YLErpDAL/Model/EodPositionRisks.cs +++ b/YLErpDAL/Model/EodPositionRisks.cs @@ -230,6 +230,8 @@ namespace YLErp.Model public double? Vega_r_1bp { get; set; } + public double? Vega_1bp { 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/CalcPriceShowConfigModule/CalcPriceShowConfigService.cs b/YLErpDAL/Modules/CalcPriceShowConfigModule/CalcPriceShowConfigService.cs index c021ef8a..c0c613dd 100644 --- a/YLErpDAL/Modules/CalcPriceShowConfigModule/CalcPriceShowConfigService.cs +++ b/YLErpDAL/Modules/CalcPriceShowConfigModule/CalcPriceShowConfigService.cs @@ -256,6 +256,7 @@ namespace YLErp.Modules.CalcPriceShowConfigModule new CalcQuotaDto{Name="Gamma_r(1bp)",Tip=""}, new CalcQuotaDto{Name="Vega_r",Tip=""}, new CalcQuotaDto{Name="Vega_r(1bp)",Tip=""}, + new CalcQuotaDto{Name="Vega(1bp)",Tip=""}, }; } diff --git a/YLErpDAL/Modules/CalculationModule/Models/TradeValueResult.cs b/YLErpDAL/Modules/CalculationModule/Models/TradeValueResult.cs index d7df7d83..64702052 100644 --- a/YLErpDAL/Modules/CalculationModule/Models/TradeValueResult.cs +++ b/YLErpDAL/Modules/CalculationModule/Models/TradeValueResult.cs @@ -515,6 +515,8 @@ namespace YLErp.Modules.CalculationModule public double? Vega_r { get; set; } public double? Vega_r_1bp { get; set; } + + public double? Vega_1bp { get; set; } } public class TradeValueResultExtend diff --git a/YLErpDAL/Modules/EodModule/EodPositionRisksQueryService.cs b/YLErpDAL/Modules/EodModule/EodPositionRisksQueryService.cs index e9a3286e..6dfaaf49 100644 --- a/YLErpDAL/Modules/EodModule/EodPositionRisksQueryService.cs +++ b/YLErpDAL/Modules/EodModule/EodPositionRisksQueryService.cs @@ -2032,6 +2032,7 @@ namespace YLErp.Modules.EodModule dic.Add("Gamma_r_1bp", OtcFormatExtensions.OtcFormat(item.Gamma_r_1bp, OtcFormatFlag.greek)); dic.Add("Vega_r", OtcFormatExtensions.OtcFormat(item.Vega_r, OtcFormatFlag.greek)); dic.Add("Vega_r_1bp", OtcFormatExtensions.OtcFormat(item.Vega_r_1bp, OtcFormatFlag.greek)); + dic.Add("Vega_1bp", OtcFormatExtensions.OtcFormat(item.Vega_1bp, OtcFormatFlag.greek)); } @@ -2143,6 +2144,7 @@ namespace YLErp.Modules.EodModule dic.Add("Gamma_r_1bp", ""); dic.Add("Vega_r", ""); dic.Add("Vega_r_1bp", ""); + dic.Add("Vega_1bp", ""); } results.Add(dic); } diff --git a/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs b/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs index 5ccfdf94..4d5e7b3b 100644 --- a/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs +++ b/YLErpDAL/Modules/EodModule/GLMSGreeksHandleService.cs @@ -164,6 +164,7 @@ namespace YLErp.Modules.EodModule dto.Dv01 = calcDto.Dv01; dto.Gamma_r_1bp = calcDto.Gamma_r_1bp; dto.Vega_r_1bp = calcDto.Vega_r_1bp; + dto.Vega_1bp = calcDto.Vega_1bp; } @@ -183,6 +184,7 @@ namespace YLErp.Modules.EodModule calRes.Dv01 = calcDto.Dv01; calRes.Gamma_r_1bp = calcDto.Gamma_r_1bp; calRes.Vega_r_1bp = calcDto.Vega_r_1bp; + calRes.Vega_1bp = calcDto.Vega_1bp; } @@ -267,6 +269,9 @@ namespace YLErp.Modules.EodModule private void handle(GreeksCalcDto dto, underlying_manager um) { + // 标准 Vega 的 1bp 变体:全资产计算,不受利率类过滤限制 + dto.Vega_1bp = dto.Vega * 0.0001; + if (!calcInstrumentTypes.Contains(um.UnderlyingInstrumentType)) { return; @@ -353,6 +358,8 @@ namespace YLErp.Modules.EodModule public double? Vega_r_1bp { get; set; } + public double? Vega_1bp { get; set; } + } public class ChinaBondIndexQuoteQueryDto diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 8f6ecfa3..279d56b4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1250,6 +1250,10 @@ namespace YLErp.Modules.SwapModule { return; } + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(tradeObj, out var errorMessage)) + { + throw new ServiceException(errorMessage); + } var eventTypes = new List() { (int)SwapEventTypeEnum.修改交易, (int)SwapEventTypeEnum.新增交易, (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; var lastEvent = DbContext.swap_event.Where(x => x.SwapTradeId == intid && eventTypes.Contains(x.EventType) && !x.Invalid).OrderByDescending(o => o.OptTime).FirstOrDefault(); if (tradeObj.TradeStatus == ConsTrade.平仓待复核 || tradeObj.TradeStatus == ConsTrade.互换待复核) diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 4e9be366..e62f6c34 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -59,7 +59,8 @@ namespace YLErp.Modules.SystemModule parentNode = item.parentNode, approvalCondition = item.approvalCondition, conditionConfig = item.conditionConfig, - triggerCondition = item.triggerCondition + triggerCondition = item.triggerCondition, + isOaApproval = item.isOaApproval }).ToList(); DbContext.approvalprocess.AddRange(list); @@ -790,6 +791,8 @@ namespace YLErp.Modules.SystemModule /// 节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。 public string triggerCondition { get; set; } + + public bool isOaApproval { get; set; } } /// /// 审批流程修改节点 diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index a10d96ac..536504ed 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -263,6 +263,10 @@ namespace YLErp.Modules.TradeModule.DealModule td.OptName = UserName; td.OptDate = OptDate; int passStatus = new ApprovalProcessService(OptUser).CheckTradeApprovalStep(td, OptUser.UserId); + if (passStatus != 0 && passStatus != -3) + { + new TradeApprovalOAService(this).EnsureForCurrentNode(td); + } if (passStatus == 0) { td.TradeStatus = ConsTrade.确认成交; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 3755dc1b..9cdda1c5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -56,6 +56,23 @@ namespace YLErp.Modules.TradeModule.DealModule throw new ServiceException("该交易状态已变更,不能被审批,请先刷新页面"); } + if (!req.isFromOa && (req.status == "pass" || req.status == "reject")) + { + var currentNode = TradeProcessByCategory(td) + .Where(x => x.order == td.ProcessOrderId && (x.node == 0 || x.node == td.ProcessOrderBranch)) + .OrderByDescending(x => x.node == td.ProcessOrderBranch) + .FirstOrDefault(); + if (currentNode?.isOaApproval == true) + { + var oa = DbContext.tradeApprovalOaResult.FirstOrDefault(x => x.trade_id == td.id + && x.approval_process_id == currentNode.id + && x.is_valid + && (x.status == "提交成功" || x.status == "同步中" || x.status == "归档中")); + if (oa != null && !new TradeApprovalOAService(this).ArchiveForLocalAction(oa, out var errorMessage)) + throw new ServiceException(errorMessage); + } + } + if (req.status == "pass") { return TradePass(req, td); @@ -179,6 +196,13 @@ namespace YLErp.Modules.TradeModule.DealModule return result; } + // 关联 OA 的节点必须先成功创建 OA 流程,才允许本地审批推进到该节点。 + if (nextOrder.isOaApproval && td.TradeType == "收益互换") + { + // OA 创建是节点通过后的外部动作;明确失败只记录在 OA 结果表,不能回滚本地节点通过。 + new TradeApprovalOAService(this).CreateForNode(td, nextOrder); + } + //----------------------------------------------- // 继续审批流转 //----------------------------------------------- @@ -788,6 +812,7 @@ namespace YLErp.Modules.TradeModule.DealModule public bool notNeedOperationHistory; public bool isPartialExercise; public bool isExpire = false; + public bool isFromOa; } /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs index c543d745..661a8c6e 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeRevokeService.cs @@ -50,6 +50,11 @@ return; } + foreach (var td in revokeTrades) + { + ArchiveActiveOaOrThrow(td); + } + var tradeids = revokeTrades.Select(n => n.id); var revokeTrade_cashs = DbContext.trade_cash.Where(c => c.ValidState == "InValid" && !c.IsDeleted && tradeids.Contains(c.TradeId)).ToList(); revokeTrade_cashs.ForEach(x => @@ -94,6 +99,7 @@ var Withdraws = DbContext.trade.Where(t => tradeIds.Contains(t.id)).ToList(); foreach (var td in Withdraws) { + ArchiveActiveOaOrThrow(td); var changesTradeStatus = DbContext.TradeAuditLog.Where(x => (x.OptType == "确认交易" || x.OptType == "交易特批-确认交易") && x.TradeId == td.id).OrderByDescending(x => x.id)?.FirstOrDefault()?.Changes; td.TradeStatus = changesTradeStatus == ConsTrade.新增待确认 ? ConsTrade.新增待确认 : ConsTrade.修改待确认; td.CheckStatus = null; @@ -103,6 +109,12 @@ } DbContext.SaveChanges(); } + + private void ArchiveActiveOaOrThrow(trade td) + { + if (!new TradeApprovalOAService(this).ArchiveActiveForTrade(td, out var errorMessage)) + throw new ServiceException(errorMessage); + } /// /// 获取包含黑箱结构的子交易集合 /// diff --git a/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs new file mode 100644 index 00000000..c9342f95 --- /dev/null +++ b/YLErpDAL/Modules/TradeModule/TradeApprovalOAService.cs @@ -0,0 +1,481 @@ +using BaseOUDAL; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Consts; +using YLErp.DBModels.Enums; +using YLErp.Modules.ApiModule; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// 收益互换审批节点的国联民生 OA 对接。 + /// 本期只负责 HTTP 创建、查询和归档;审批结果由轮询任务回写本地流程。 + /// + public class TradeApprovalOAService : YLBaseService + { + private const string Pending = "提交成功"; + private const string Syncing = "同步中"; + private const string Ending = "归档中"; + private readonly IYcLogger _logger = LogFactory.GetLogger(); + + public TradeApprovalOAService(OptUserInfo userInfo) : base(userInfo) { } + public TradeApprovalOAService(YLBaseService baseService) : base(baseService) { } + + public bool IsSupported(trade trade) + { + return trade != null && trade.TradeType == "收益互换"; + } + + public trade_approval_oa_result CreateForNode(trade trade, approvalprocess node) + { + if (!IsSupported(trade) || node == null || !node.isOaApproval) + return null; + + var scenario = node.processType == "TradeProcess" + ? "开仓" + : (trade.TradeStatus == ConsTrade.平仓待复核 ? "平仓" : "收益结算"); + _logger.Info($"开始创建 OA 移动审批,交易:{trade.id},节点:{node.id},场景:{scenario}"); + var record = new trade_approval_oa_result + { + trade_id = trade.id, + approval_process_id = node.id, + process_type = node.processType, + applicant_id = UserId, + applicant_login_name = Config("Applicant"), + status = "提交中", + is_valid = true + }; + record.SetOpt(UserInfo); + DbContext.tradeApprovalOaResult.Add(record); + DbContext.SaveChanges(); + + try + { + var creator = Config("Creator"); + var applicant = Config("Applicant"); + if (string.IsNullOrWhiteSpace(creator) || string.IsNullOrWhiteSpace(applicant)) + throw new ServiceException("OA 配置缺少 Creator 或 Applicant"); + + var spr = GetSprLoginName(node); + var payload = BuildPayload(trade, creator, applicant, spr, scenario); + record.request_payload = JsonHelper.Serialize(payload); + var responseText = Post("SubUrl", payload); + record.last_response = responseText; + var response = JObject.Parse(responseText); + var responseData = response["data"] as JObject; + var responseCode = (int?)response["code"]; + var responseStatus = (string)response["status"]; + _logger.Info($"OA 创建流程原始响应,交易:{trade.id},节点:{node.id},响应:{responseText}"); + if ((int?)response["code"] != 0 || !string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + record.status = "提交失败"; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"] ?? "OA 创建流程失败"; + _logger.Error($"创建 OA 移动审批返回失败,交易:{trade.id},节点:{node.id},code:{responseCode},status:{responseStatus}"); + } + else + { + record.status = Pending; + record.oa_fileid = (string)responseData?["requestid"] ?? (string)responseData?["requestId"]; + record.oa_msg = (string)responseData?["msg"] ?? (string)response["msg"]; + if (string.IsNullOrWhiteSpace(record.oa_fileid)) + { + record.status = "提交失败"; + _logger.Error($"创建 OA 移动审批未返回 requestId,交易:{trade.id},节点:{node.id}"); + } + else + { + _logger.Info($"创建 OA 移动审批成功,交易:{trade.id},节点:{node.id},requestId:{record.oa_fileid}"); + } + } + } + catch (Exception ex) + { + record.status = "提交失败"; + record.oa_msg = ex.Message; + _logger.Error($"创建 OA 移动审批失败,交易:{trade.id},节点:{node.id}", ex); + } + DbContext.SaveChanges(); + return record; + } + + /// + /// 交易首次进入审批时,确保当前 OA 节点已创建对应流程。 + /// + public trade_approval_oa_result EnsureForCurrentNode(trade trade) + { + if (!IsSupported(trade) || trade.ProcessOrderId <= 0) + return null; + + var node = GetCurrentNode(trade); + if (node?.isOaApproval != true) + return null; + + var hasActiveRecord = DbContext.tradeApprovalOaResult.Any(x => x.trade_id == trade.id + && x.approval_process_id == node.id + && x.is_valid + && (x.status == "提交中" || x.status == Pending || x.status == Syncing || x.status == Ending)); + return hasActiveRecord ? null : CreateForNode(trade, node); + } + + /// + /// 本地撤回前归档交易仍在审批中的 OA 流程。 + /// + public bool ArchiveActiveForTrade(trade trade, out string errorMessage) + { + errorMessage = null; + if (!IsSupported(trade)) + return true; + + var records = DbContext.tradeApprovalOaResult + .Where(x => x.trade_id == trade.id + && x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (x.status == Pending || x.status == Syncing || x.status == Ending)) + .OrderBy(x => x.id) + .ToList(); + if (records.Count == 0) + { + _logger.Info($"本地撤回前未找到待归档 OA 流程,交易:{trade.id}"); + return true; + } + + foreach (var record in records) + { + if (!ArchiveForLocalAction(record, out errorMessage)) + return false; + } + return true; + } + + /// + /// 本地审批或撤回前归档 OA。通过条件更新抢占记录,避免与轮询回写同时推进同一节点。 + /// + public bool ArchiveForLocalAction(trade_approval_oa_result record, out string errorMessage) + { + errorMessage = null; + if (record == null) + return true; + + _logger.Info($"本地操作前开始归档 OA 流程,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + + if (!TryChangeStatus(record.id, Pending, Ending, null)) + { + _logger.Info($"本地操作前归档 OA 流程未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 审批结果正在同步,请稍后再试"; + return false; + } + + if (!ForceEnd(record.oa_fileid)) + { + TryChangeStatus(record.id, Ending, Pending, "OA 流程归档失败,等待重试"); + _logger.Error($"本地操作前归档 OA 流程失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + errorMessage = "OA 流程归档失败,暂不能执行本地审批操作"; + return false; + } + + TryChangeStatus(record.id, Ending, "已归档", "本地操作前已归档 OA 流程"); + _logger.Info($"本地操作前归档 OA 流程成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + return true; + } + + public JObject Query(string requestId) + { + var query = "requestId=" + Uri.EscapeDataString(requestId ?? string.Empty) + + "&systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + var queryUrl = Config("TradeApprovalQueryUrl"); + if (string.IsNullOrWhiteSpace(queryUrl)) + throw new ServiceException("OA 配置缺少 TradeApprovalQueryUrl"); + var url = queryUrl + (queryUrl.Contains("?") ? "&" : "?") + query; + var client = new HttpClientWrap(Config("BaseUrl"), 60); + return JObject.Parse(client.Get(url, null)); + } + + public bool ForceEnd(string requestId) + { + try + { + // 客户 OA 的 requestId 由 @RequestParam 接收,必须放在 URL QueryString。 + var response = JObject.Parse(Post("ForceEndUrl", new { }, requestId)); + var code = (int?)response["code"]; + var status = (string)response["status"]; + var responseData = response["data"] as JObject; + var resultCode = (string)responseData?["resultcode"]; + var success = code == 0 + && string.Equals((string)response["status"], "SUCCESS", StringComparison.OrdinalIgnoreCase) + && resultCode == "0"; + _logger.Info($"OA 强制归档返回,requestId:{requestId},code:{code},status:{status},resultcode:{resultCode},success:{success}"); + return success; + } + catch (Exception ex) + { + _logger.Error($"归档 OA 流程失败,requestId:{requestId}", ex); + return false; + } + } + + public List SyncPendingStatuses(string requestId = null) + { + var messages = new List(); + var staleAt = DateTime.Now.AddMinutes(-5); + var pending = DbContext.tradeApprovalOaResult + .Where(x => x.is_valid + && !string.IsNullOrWhiteSpace(x.oa_fileid) + && (string.IsNullOrWhiteSpace(requestId) || x.oa_fileid == requestId) + && (x.status == Pending || (x.status == Syncing && (x.last_query_time == null || x.last_query_time < staleAt)))) + .ToList(); + _logger.Info($"开始轮询 OA 移动审批状态,requestId:{requestId ?? "全部"},待查询数量:{pending.Count}"); + foreach (var record in pending) + { + var claimed = false; + try + { + _logger.Info($"查询 OA 移动审批状态,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + var response = Query(record.oa_fileid); + record.last_response = response.ToString(); + var flow = response["data"] is JArray data + ? data.FirstOrDefault(x => (string)x["requestId"] == record.oa_fileid) as JObject + : null; + var flowStatusType = (string)flow?["flowStatusType"]; + var flowNode = (string)flow?["flowNode"]; + var isReturned = flowStatusType == "0" && flowNode == "退回"; + // 1=批准;3=OA 批准后的自然归档。TRS 主动归档的记录不会进入本次查询。 + var isApproved = flowStatusType == "1" || flowStatusType == "3"; + _logger.Info($"OA 移动审批查询结果,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},flowStatusType:{flowStatusType},flowNode:{flowNode},判定:{(isReturned ? "退回" : isApproved ? "通过" : "等待")}"); + if (!isApproved && !isReturned) + { + record.last_query_time = DateTime.Now; + DbContext.SaveChanges(); + continue; + } + + DbContext.SaveChanges(); + if (!TryClaimForSync(record.id)) + { + _logger.Info($"OA 审批结果同步未抢占记录,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid}"); + continue; + } + claimed = true; + + var trade = DbContext.trade.Find(record.trade_id); + if (trade == null) + { + SetClaimedStatus(record.id, "同步失败", "交易不存在"); + continue; + } + if (GetCurrentNode(trade)?.id != record.approval_process_id) + { + SetClaimedStatus(record.id, "同步忽略", "当前审批节点已变化,忽略 OA 结果"); + continue; + } + var result = new TradeOpenService(this).UpdateTradeProcessLog(new TradeOpenReqModel + { + tradeId = trade.id, + status = isReturned ? "reject" : "pass", + comments = isReturned ? "OA 移动审批退回" : "OA 移动审批通过", + isFromOa = true, + notNeedOperationHistory = false + }); + if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) + { + SetClaimedStatus(record.id, Pending, result.ErrorMsg); + _logger.Error($"OA 审批结果回写本地失败,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},原因:{result.ErrorMsg}"); + } + else + { + SetClaimedStatus(record.id, isReturned ? "退回" : "通过", null); + _logger.Info($"OA 审批结果回写本地成功,交易:{record.trade_id},节点:{record.approval_process_id},requestId:{record.oa_fileid},结果:{(isReturned ? "退回" : "通过")}"); + } + messages.Add(record.trade_id + ":" + (string.IsNullOrWhiteSpace(result.ErrorMsg) ? (isReturned ? "退回" : "通过") : Pending)); + } + catch (Exception ex) + { + if (claimed) + { + SetClaimedStatus(record.id, Pending, ex.Message); + } + else + { + record.last_query_time = DateTime.Now; + record.oa_msg = ex.Message; + DbContext.SaveChanges(); + } + _logger.Error($"轮询 OA 审批状态失败,requestId:{record.oa_fileid}", ex); + } + } + _logger.Info($"OA 移动审批状态轮询结束,已处理数量:{messages.Count}"); + return messages; + } + + private string Post(string urlKey, object payload, string requestId = null) + { + var path = Config(urlKey); + if (string.IsNullOrWhiteSpace(path)) + path = urlKey == "ForceEndUrl" ? "/gateway/oaflow/forceEndOaFlow" : "/gateway/oaflow/createOaFlow"; + var query = "systemToken=" + Uri.EscapeDataString(Config("SystemToken")) + + "&systemName=" + Uri.EscapeDataString(Config("SystemName")); + if (urlKey == "ForceEndUrl") + query += "&requestId=" + Uri.EscapeDataString(requestId ?? string.Empty); + else + query += "&flowId=" + Uri.EscapeDataString(Config("MobileFlowId", "245103")) + + "&isnextflow=" + Uri.EscapeDataString(Config("TradeApprovalIsNextFlow", "0")); + var url = path + (path.Contains("?") ? "&" : "?") + query; + return new HttpClientWrap(Config("BaseUrl"), 60).PostJson(url, payload, null); + } + + private string Config(string key, string fallback = "") + { + if (key == "BaseUrl" || key == "SubUrl" || key == "TradeApprovalQueryUrl" || key == "ForceEndUrl") + { + var mockEnabled = bool.TryParse(AppManager.GetConfiguration()["TradeApprovalOaMock:Enabled"], out var enabled) && enabled; + var mockValue = AppManager.GetConfiguration()[$"TradeApprovalOaMock:{key}"]; + if (mockEnabled && !string.IsNullOrWhiteSpace(mockValue)) + return mockValue; + } + var value = AppManager.GetConfiguration()[$"oa_confg:{key}"]; + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + + private approvalprocess GetCurrentNode(trade trade) + { + if (trade == null) return null; + var processType = IsCloseScenario(trade) ? "CloseProcess" : "TradeProcess"; + return DbContext.approvalprocess + .Where(x => x.processType == processType + && x.order == trade.ProcessOrderId + && (x.node == trade.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == trade.ProcessOrderBranch) + .FirstOrDefault(); + } + + private static bool IsCloseScenario(trade trade) + { + return trade.TradeStatus == ConsTrade.平仓待复核 + || trade.TradeStatus == ConsTrade.行权待复核 + || trade.TradeStatus == ConsTrade.互换待复核; + } + + private bool TryClaimForSync(int recordId) + { + var now = DateTime.Now; + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, last_query_time = {1} " + + "WHERE id = {2} AND is_valid = 1 AND (status = {3} OR (status = {0} AND (last_query_time IS NULL OR last_query_time < {4})))", + Syncing, now, recordId, Pending, now.AddMinutes(-5)) == 1; + } + + private bool TryChangeStatus(int recordId, string expectedStatus, string targetStatus, string message) + { + return DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + targetStatus, message, recordId, expectedStatus) == 1; + } + + private void SetClaimedStatus(int recordId, string status, string message) + { + DbContext.Database.ExecuteSqlRaw( + "UPDATE trade_approval_oa_result SET status = {0}, oa_msg = {1} WHERE id = {2} AND is_valid = 1 AND status = {3}", + status, message, recordId, Syncing); + } + + private string GetSprLoginName(approvalprocess node) + { + using var baseDb = DbContextFactory.GetErpBaseContext(); + var users = (from roleUser in baseDb.RoleUsers + join user in baseDb.SystemUsers on roleUser.UserId equals user.Id + where roleUser.RoleId == node.roleId && user.State == (int)UserState.Enabled + select user.LoginName).ToList(); + if (users.Count == 0) + throw new ServiceException($"关联 OA 的审批角色未配置启用用户,角色ID:{node.roleId}"); + if (users.Count > 1) + throw new ServiceException($"关联 OA 的审批角色配置了多个启用用户,角色ID:{node.roleId}"); + if (string.IsNullOrWhiteSpace(users[0])) + throw new ServiceException($"关联 OA 的审批用户未配置登录名,角色ID:{node.roleId}"); + return users[0]; + } + + private Dictionary BuildPayload(trade trade, string creator, string applicant, string spr, string scenario) + { + var positions = DbContext.swap_position + .Where(x => x.SwapTradeId == trade.id && x.IsInitial && !x.Invalid) + .ToList(); + var floatPosition = positions.FirstOrDefault(x => x.IsInitial && !string.IsNullOrWhiteSpace(x.UnderlyingCode)); + var underlyingName = floatPosition == null + ? string.Empty + : DbContext.underlying_manager.Where(x => x.UnderlyingCode == floatPosition.UnderlyingCode) + .Select(x => x.UnderlyingName) + .FirstOrDefault() ?? string.Empty; + var interestPositions = positions.Where(x => string.IsNullOrWhiteSpace(x.UnderlyingCode) + && x.InterestMode != (int)InterestModeEnum.初始预付金 + && x.InterestMode != (int)InterestModeEnum.追加预付金 + && x.InterestMode != (int)InterestModeEnum.Unknown).ToList(); + var marginPositions = positions.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + || x.InterestMode == (int)InterestModeEnum.追加预付金).ToList(); + var tradeDate = trade.TradeDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var startDate = floatPosition?.PosiStartDate.ToString("yyyy-MM-dd") ?? string.Empty; + var maturityDate = floatPosition?.PosiMatuirityDate?.ToString("yyyy-MM-dd") ?? string.Empty; + var contractDays = floatPosition?.PosiMatuirityDate == null + ? string.Empty + : ((floatPosition.PosiMatuirityDate.Value.Date - floatPosition.PosiStartDate.Date).Days).ToString(); + + var elementLines = new List + { + "交易日期=" + tradeDate, + "审批角色=衍生品负责人", + "交易编号=" + (trade.TradeNumber ?? string.Empty), + "对手方=" + (trade.ClientName ?? string.Empty), + "交易类型=" + scenario, + "浮动端多空头=" + (floatPosition == null ? string.Empty : floatPosition.PositionType == 1 ? "多头" : "空头"), + "利息端方向=" + JoinValues(interestPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")), + "簿记账户=" + (trade.AssetBookName ?? string.Empty), + "结构类型=" + (trade.StructureType ?? string.Empty), + "标的代码=" + (floatPosition?.UnderlyingCode ?? string.Empty), + "标的名称=" + underlyingName, + "期初收益率=" + (floatPosition?.InitYtm?.ToString("0.####%") ?? string.Empty), + "期初价格=" + (floatPosition?.PosiNetPrice.ToString("0.####") ?? string.Empty), + "行权方式=" + (trade.GetExerciseModeCn() ?? string.Empty), + "合约期限=" + contractDays, + "数量=" + (floatPosition?.PosiQuantity.ToString("0.####") ?? string.Empty), + "起始日=" + startDate, + "到期日=" + maturityDate, + "交易员=" + (trade.TraderName ?? string.Empty), + "支付日期=" + JoinValues(marginPositions.Select(x => x.HappenDate?.ToString("yyyy-MM-dd") ?? string.Empty)), + "期初预付金=" + JoinValues(marginPositions.Select(x => x.InterestPrincipalFix.ToString("0.00"))), + "期初预付金方向=" + JoinValues(marginPositions.Select(x => x.InterestDirection == 1 ? "收取" : "支付")) + }; + + if (scenario == "开仓") + { + elementLines.Add("平仓金额="); + elementLines.Add("平仓日期="); + } + else + { + var eventType = scenario == "平仓" ? (int)SwapEventTypeEnum.平仓 : (int)SwapEventTypeEnum.互换; + var swapEvent = DbContext.swap_event.Where(x => x.SwapTradeId == trade.id && !x.Invalid && x.EventType == eventType) + .OrderByDescending(x => x.id).FirstOrDefault(); + var unwind = string.IsNullOrWhiteSpace(swapEvent?.EventData) ? null : JsonHelper.Deserialize(swapEvent.EventData); + elementLines.Add("平仓金额=" + (unwind?.SwapCloseAmount.ToString("0.##") ?? "")); + elementLines.Add("平仓日期=" + (unwind?.UnwindDate?.ToString("yyyy-MM-dd") ?? swapEvent?.ValueDate.ToString("yyyy-MM-dd") ?? "")); + } + + var elements = string.Join("
", elementLines); + var payload = new Dictionary + { + ["title"] = "衍生品系统TRS移动审批-" + trade.TradeNumber, + ["creater"] = creator, + ["applicant"] = applicant, + ["spr"] = spr, + ["applyDate"] = DateTime.Now.ToString("yyyy-MM-dd"), + ["elements"] = elements + }; + return payload; + } + + private static string JoinValues(IEnumerable values) + { + return string.Join(";", values.Where(x => !string.IsNullOrWhiteSpace(x))); + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 62e42170..004baef5 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -287,6 +287,13 @@ namespace YLErp.BLL .Select(a => new { a.order, a.roleId }) .ToList() .ToDictionary(a => a.order, a => a.roleId); + var approvalNodes = db.approvalprocess + .Select(a => new { a.id, a.processType, a.order, a.node }) + .ToList(); + var oaRecords = db.tradeApprovalOaResult + .Where(x => swapTradeIds.Contains(x.trade_id) && x.is_valid) + .Select(x => new { x.id, x.trade_id, x.approval_process_id, x.status, x.oa_msg }) + .ToList(); foreach (var tradeLinq in retListResult.rows) { @@ -305,6 +312,29 @@ namespace YLErp.BLL tradeLinq.ProcessRoleName = name; } + if (tradeLinq.TradeType == "收益互换") + { + var processType = tradeLinq.TradeStatus == "平仓待复核" || tradeLinq.TradeStatus == "行权待复核" || tradeLinq.TradeStatus == "互换待复核" + ? "CloseProcess" + : "TradeProcess"; + var currentNode = approvalNodes + .Where(x => x.processType == processType + && x.order == tradeLinq.ProcessOrderId + && (x.node == tradeLinq.ProcessOrderBranch || x.node == 0)) + .OrderByDescending(x => x.node == tradeLinq.ProcessOrderBranch) + .FirstOrDefault(); + var oa = currentNode == null ? null : oaRecords + .Where(x => x.trade_id == tradeLinq.id && x.approval_process_id == currentNode.id) + .OrderByDescending(x => x.id) + .FirstOrDefault(); + if (oa != null) + { + tradeLinq.OaRemark = string.IsNullOrWhiteSpace(oa.oa_msg) + ? oa.status + : oa.status + ":" + oa.oa_msg; + } + } + if (tradeLinq.TradeType == "远期" || tradeLinq.TradeType == "掉期") { var option = db.trade_forward.FirstOrDefault(x => x.TradeId == tradeLinq.id); diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index 7d45a901..aa715929 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -120,6 +120,7 @@ namespace YLErp.Modules.TradeModule // 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。 // 若所有节点均不满足 → 直接审批通过(无需任何人审核)。 ApplyTriggerFromStart(tradeProcess, td, userId); + new TradeApprovalOAService(this).EnsureForCurrentNode(td); } /// diff --git a/YLErpWeb/App/HostedTaskService.cs b/YLErpWeb/App/HostedTaskService.cs index 025420b8..9bbad9d4 100644 --- a/YLErpWeb/App/HostedTaskService.cs +++ b/YLErpWeb/App/HostedTaskService.cs @@ -6,6 +6,7 @@ using YLErp.Modules.EodModule.SettlementModule; using YLErp.Modules.RiskEngine; using YLErp.Modules.RiskModule; using YLErp.Modules.SuperviseReportModule.SAC.Service; +using YLErp.Modules.TradeModule; using YLErp.Modules.TradeRiskCalcModule; namespace YLErp.Web.App @@ -17,6 +18,9 @@ namespace YLErp.Web.App public class HostedTaskService : IHostedService { Timer _timer, _timerMonitor; + private int _tradeApprovalOaSyncRunning; + private DateTime _lastTradeApprovalOaSyncTime = DateTime.MinValue; + private readonly IYcLogger _logger = LogFactory.GetLogger(); private readonly IServer server; private readonly IHostApplicationLifetime hostApplicationLifetime; @@ -44,6 +48,8 @@ namespace YLErp.Web.App }); } + TrySyncTradeApprovalOaStatus(); + if (AppHelper.RunTradeRiskCalcV2) { Task.Run(() => @@ -86,6 +92,45 @@ namespace YLErp.Web.App }); } + private void TrySyncTradeApprovalOaStatus() + { + var intervalSeconds = PS.Config?.ErpElement.TradeApprovalOaSyncIntervalSeconds ?? 30; + if (intervalSeconds <= 0) + { + intervalSeconds = 30; + } + + if (_lastTradeApprovalOaSyncTime != DateTime.MinValue + && DateTime.Now - _lastTradeApprovalOaSyncTime < TimeSpan.FromSeconds(intervalSeconds)) + { + return; + } + + if (Interlocked.CompareExchange(ref _tradeApprovalOaSyncRunning, 1, 0) != 0) + { + return; + } + + Task.Run(() => + { + try + { + var results = new TradeApprovalOAService(new OptUserInfo(0, "系统移动审批同步", OptUserFrom.System)) + .SyncPendingStatuses(); + _logger.Info($"OA 移动审批状态定时同步完成,处理数量:{results.Count}"); + } + catch (Exception ex) + { + _logger.Error("OA 移动审批状态定时同步失败", ex); + } + finally + { + _lastTradeApprovalOaSyncTime = DateTime.Now; + Interlocked.Exchange(ref _tradeApprovalOaSyncRunning, 0); + } + }); + } + public Task StartAsync(CancellationToken cancellationToken) { object _state = System.Diagnostics.Debugger.IsAttached ? null : this; diff --git a/YLErpWeb/App/Startup.cs b/YLErpWeb/App/Startup.cs index 7616e956..b44712ac 100644 --- a/YLErpWeb/App/Startup.cs +++ b/YLErpWeb/App/Startup.cs @@ -20,7 +20,7 @@ namespace YLErp.Web GlobalConfig.EnableGenerateOnTradeClosed = true; GlobalConfig.OverrideOldDoc = true; - GlobalConfig.ProjectTitle = "场外衍生品交易管理系统"; + GlobalConfig.ProjectTitle = "固收场外衍生品系统"; GlobalConfig.GetNameByUserId = UserBLL.GetNameById; GlobalConfig.SealHandler = new SealTaskHandleService(OptUserInfo.SystemUser); diff --git a/YLErpWeb/Areas/Admin/Views/OtcConfig/MailTemplate.cshtml b/YLErpWeb/Areas/Admin/Views/OtcConfig/MailTemplate.cshtml index b83c1f7a..e62911ca 100644 --- a/YLErpWeb/Areas/Admin/Views/OtcConfig/MailTemplate.cshtml +++ b/YLErpWeb/Areas/Admin/Views/OtcConfig/MailTemplate.cshtml @@ -13,6 +13,10 @@ + @* EQD-5320 资金通知书:value 与 bond-oms Java FundEmailHandler 查询的 EmailType 一字不差,勿改名; + 此场景 {{文档编号}}/{{交易列表}} 渲染为空,可用 {{客户名称}}/{{交易日期}} *@ + +
diff --git a/YLErpWeb/Controllers/AccountController.cs b/YLErpWeb/Controllers/AccountController.cs index 3efa8a20..d70ff4b0 100644 --- a/YLErpWeb/Controllers/AccountController.cs +++ b/YLErpWeb/Controllers/AccountController.cs @@ -32,6 +32,7 @@ namespace YLErp.Web.Controllers // 如果上次登录失败,则显示验证码输入,否则不需要 //--------------------------------------- + [ResponseCache(NoStore = true)] public ActionResult Login(bool skipSso = false) { var logger = LogFactory.GetLogger(); @@ -69,7 +70,7 @@ namespace YLErp.Web.Controllers // 注意:DisablePasswordLogin 的逻辑在 Login.cshtml 视图中处理 // 这里不再直接跳转,而是显示登录页,由页面决定显示什么内容 - return LoginError(null); + return View(); } public ActionResult Login2() @@ -77,6 +78,14 @@ namespace YLErp.Web.Controllers return Login(true); } + /// + /// 内置超管账密登录兜底页 + /// + public ActionResult AdminLogin() + { + return LoginError(null); + } + /// /// 登录错误 /// @@ -113,7 +122,7 @@ namespace YLErp.Web.Controllers model.SID = DataProtectHelper.Encrypt(sid); } - return View(nameof(Login), model); + return View(nameof(AdminLogin), model); } [HttpPost, ResponseCache(NoStore = true)] diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index bafb8382..479e70ab 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -1237,7 +1237,8 @@ namespace YLErp.Web.Controllers /// EQD-5320 批量发送结算确认书邮件——经服务端代理 bond-oms(BondOmsInterface_BaseUrl 出口), /// 替代前端直连 /trs_hub_api 反向代理(未配 nginx 的环境 404)。 ///
- /// 平仓流水id,逗号分隔 + /// 平仓流水id,逗号分隔;单个令牌兼容【数字】与【EncryptId 加密串】两种形态 + /// (批量按钮传网格数字id,单行按钮传 swap_flow_event.EncryptId——全站 enid 惯例) [HttpPost] public JsonResult BatchSendSettleEmail(string swapFlowEventIds) { @@ -1248,14 +1249,21 @@ namespace YLErp.Web.Controllers var ids = new List(); foreach (var s in swapFlowEventIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { - if (long.TryParse(s.Trim(), out var id)) + var token = s.Trim(); + long id; + if (!long.TryParse(token, out id) || id <= 0) + { + // 非数字令牌按 EncryptId 解密(解密失败/非法串返回0,不抛出) + try { id = DecryptLong(token); } catch { id = 0; } + } + if (id > 0) { ids.Add(id); } } if (ids.Count == 0) { - return JsonError("平仓流水id解析为空"); + return JsonError("平仓流水id解析为空(既非数字也非有效加密ID):" + swapFlowEventIds); } var result = new SwapEndConfirmService(CurUser).BatchSendSettleEmail(ids); return string.IsNullOrEmpty(result) ? JsonSuccess("发送成功") : JsonError(result); 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/Account/AdminLogin.cshtml b/YLErpWeb/Views/Account/AdminLogin.cshtml new file mode 100644 index 00000000..f26d20bc --- /dev/null +++ b/YLErpWeb/Views/Account/AdminLogin.cshtml @@ -0,0 +1,166 @@ +@model LogOnModel + +@{ + Layout = null; + ViewBag.Title = "登录"; + var captchaUrl = Url.Action("Captcha") + "?sid=" + Model.SID; + Model.LoadTime = DateTime.Now; + var disablePasswordLogin = PS.Config?.ErpElement?.DisablePasswordLogin ?? false; + var ssoEnable = YLErp.Web.Models.GeneralSSOConfig.Enable; +} + + + + + + @GlobalConfig.ProjectTitle + + + + + + + + +
@(Model.Exception?.ToString())
+ + + + diff --git a/YLErpWeb/Views/Account/Login.cshtml b/YLErpWeb/Views/Account/Login.cshtml index f71d0ea7..3b6479e1 100644 --- a/YLErpWeb/Views/Account/Login.cshtml +++ b/YLErpWeb/Views/Account/Login.cshtml @@ -1,12 +1,8 @@ -@model LogOnModel - @{ Layout = null; ViewBag.Title = "登录"; - var captchaUrl = Url.Action("Captcha") + "?sid=" + Model.SID; - Model.LoadTime = DateTime.Now; - var disablePasswordLogin = PS.Config?.ErpElement?.DisablePasswordLogin ?? false; var ssoEnable = YLErp.Web.Models.GeneralSSOConfig.Enable; + var authUrl = YLErp.Web.Models.GeneralSSOConfig.AuthUrl; } @@ -14,153 +10,96 @@ @GlobalConfig.ProjectTitle - - - + .login-page { position: relative; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 80px 24px 40px; } + .login-main { margin: auto; width: 100%; max-width: 420px; display: flex; flex-direction: column; align-items: center; } + .login-header { position: absolute; top: 30px; left: 34px; display: flex; align-items: center; gap: 10px; } + .login-header .brand { font-size: 23px; letter-spacing: -0.055em; color: #1D1A1A; font-weight: 400; line-height: 1; } + .login-header .brand b { font-weight: 800; color: #C0221E; } + .login-header .sub { display: block; margin-top: 3px; font-size: 11px; letter-spacing: 0.055em; color: #938A8A; } + .login-title { text-align: center; margin-bottom: 24px; } + .login-title h1 { font-size: 24px; line-height: 36px; letter-spacing: -0.025em; color: #1F1F1F; font-weight: 700; } + .login-title h1 .accent { color: #C0221E; font-weight: 800; letter-spacing: -0.04em; } + .login-title p { margin-top: 8px; font-size: 13px; line-height: 19.5px; color: #ADADAD; } + .login-card { width: 380px; max-width: 100%; padding: 36px 36px 32px; background: #FFFFFF; border-radius: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.06), 0 10px 48px rgba(0,0,0,.1); text-align: center; } + .sso-icon { display: flex; align-items: center; justify-content: center; width: 80px; height: 80px; margin: 0 auto; border-radius: 40px; background: linear-gradient(135deg, #F5E8E8 0%, #F9E0E0 100%); box-shadow: 0 6px 24px rgba(192,34,30,.14); } + .sso-icon img { width: 36px; height: 36px; } + .sso-title { margin-top: 16px; font-size: 14px; font-weight: 600; line-height: 21px; color: #1F1F1F; } + .sso-desc { margin-top: 4px; font-size: 12px; line-height: 19.2px; color: #ADADAD; } + .sso-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; height: 51px; margin-top: 32px; border: none; border-radius: 10px; background: #C0221E; box-shadow: 0 4px 18px rgba(192,34,30,.3); color: #fff; font-size: 15px; font-weight: 700; letter-spacing: 0.04em; text-decoration: none; cursor: pointer; transition: opacity .2s; } + .sso-btn:hover { opacity: .9; color: #fff; text-decoration: none; } + .sso-btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; line-height: 0; } + .sso-btn img { width: 17px; height: 17px; display: block; } + .sso-btn-text { line-height: 1; } + .sso-btn.disabled { background: #ccc; box-shadow: none; cursor: not-allowed; pointer-events: none; } + .sso-btn.loading { background: #E04B48; box-shadow: 0 4px 18px rgba(192,34,30,.25); pointer-events: none; opacity: 1; color: #fff; } + @@keyframes spin { to { transform: rotate(360deg); } } + .sso-spinner { width: 17px; height: 17px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; } + .sso-error { margin-top: 10px; font-size: 12px; color: #ff6b6b; } + .admin-link { position: absolute; bottom: 16px; left: 24px; font-size: 12px; color: #ADADAD; text-decoration: none; } + .admin-link:hover { color: #C0221E; } + - -