From 8d58ef15e8bc12f88baa5a988fa29c5c9307aacd Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 6 Aug 2026 16:00:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=9C=A8=E5=B9=B3=E4=BB=93=E6=97=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A3=8E=E6=8E=A7=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E9=80=9A=E7=94=A8=E8=B6=85=E6=97=B6=E6=95=99?= =?UTF-8?q?=E7=A0=94=E5=87=BD=E6=95=B0=EF=BC=8C=E4=BB=A5=E5=A4=87=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E7=A1=AE=E8=AE=A4=E8=80=81=E9=A3=8E=E6=8E=A7=E4=B8=8B?= =?UTF-8?q?=E7=BA=BF=E5=90=8E=E6=8E=A5=E5=85=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 6 + .../RiskEngine/Helper/RiskCalendarHelper.cs | 31 +- YLErpDAL/Modules/RiskEngine/RiskContext.cs | 5 + .../Modules/RiskEngine/RiskTrialService.cs | 268 ++++++++++++++++++ .../Modules/RiskModule/QuotaMonitorService.cs | 34 +++ .../Modules/SwapModule/SwapDealService.cs | 63 +++- YLErpWeb/Controllers/SwapTrade2Controller.cs | 111 +++++++- .../Scripts/app/swaptrade/unwindSwapTrade.js | 74 ++++- 8 files changed, 561 insertions(+), 31 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/RiskTrialService.cs diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index c8b74717..0890d97e 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -26,6 +26,11 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期 + /// + public DateTime? UnwindDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// @@ -396,6 +401,7 @@ namespace YLErp.Modules.RiskEngine { TradeId = ctx.TradeId, TriggerPoint = ctx.TriggerPoint, + UnwindDate = ctx.UnwindDate, DbContext = ctx.DbContext }; return runner(globals).GetAwaiter().GetResult(); diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index 5109aee0..07dee68c 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -33,6 +33,25 @@ namespace YLErp.Modules.RiskEngine return GetPreviousTradingDay(dbContext, date, "IB", "银行间"); } + /// + /// 判断指定日期是否为银行间交易日。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 待判断的平仓日期。 + /// 日期不在银行间非交易日集合中时返回 true。 + /// 数据库上下文为空。 + /// 缺少银行间日历或日历内容异常。 + public static bool IsInterbankTradingDay(YLContext dbContext, DateTime date) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); + var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache); + var dateText = date.Date.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); + return !holidays.Contains(dateText); + } + public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) { return GetPreviousTradingDay(dbContext, date, "CHN", "交易所"); @@ -77,11 +96,13 @@ namespace YLErp.Modules.RiskEngine if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; - // 同一年可能存在多种市场日历,按规则对应的市场代码读取非交易日。 - var calendar = dbContext.calendar - .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) - .ToList() - .FirstOrDefault(c => string.Equals(c.Country, country, StringComparison.OrdinalIgnoreCase)); + // 在数据库端同时按年份和市场代码筛选,避免加载同一年份的其他市场日历。 + var normalizedCountry = country.ToUpperInvariant(); + var calendar = dbContext.calendar.FirstOrDefault(c => + c.Year == year + && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) + && c.Country != null + && c.Country.ToUpper() == normalizedCountry); if (calendar == null) throw new Exception($"未找到{year}年{calendarName}日历"); diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs index 9686f980..a87f9f7c 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskContext.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -17,6 +17,11 @@ namespace YLErp.Modules.RiskEngine /// public string TriggerPoint { get; set; } + /// + /// 当前平仓请求提交的平仓日期,供平仓阶段规则直接使用。 + /// + public DateTime? UnwindDate { get; set; } + /// /// 数据库上下文,供规则公式直接查询数据库 /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs new file mode 100644 index 00000000..442c84ed --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskTrialService.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Qdp.Foundation.Utilities; +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Enums; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 新风控通用试算服务,负责按业务触发时点执行规则、保存试算结果并校验二次确认。 + /// + public class RiskTrialService : YLBaseService + { + private readonly IYcLogger _logger = LogFactory.GetLogger("RiskTrialService"); + + /// + /// 使用当前业务服务的用户和数据库上下文创建新风控试算服务。 + /// + /// 当前业务服务。 + public RiskTrialService(YLBaseService baseService) : base(baseService) + { + } + + /// + /// 执行一次独立于老风控的新风控试算并保存试算记录。 + /// + /// 包含交易、触发时点及业务日期的风控上下文。 + /// 试算来源,由具体业务场景约定。 + /// 二次确认信息;首次试算传空。 + /// 包含阻断、审批、提示及试算记录ID的统一结果。 + public RiskTrialResult CheckRisk(RiskContext context, int trialSource, RiskTrialConfirmation confirmation = null) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (context.TradeId <= 0) + { + throw new ArgumentException("交易ID必须大于0", nameof(context)); + } + if (string.IsNullOrWhiteSpace(context.TriggerPoint)) + { + throw new ArgumentException("风控触发时点不能为空", nameof(context)); + } + + var confirmedRuleIds = new HashSet(); + var confirmationExpired = false; + if (confirmation != null) + { + confirmationExpired = !TryGetConfirmedRuleIds(context, trialSource, confirmation, confirmedRuleIds); + } + + var riskResult = new RiskEngineService(this).EvaluateRisk(context, context.TriggerPoint); + var currentApprovalRules = riskResult.TriggeredRules + .Where(rule => rule.ControlStrategy == RiskControlStrategy.Approval) + .ToList(); + var pendingApprovalRules = confirmationExpired + ? currentApprovalRules + : currentApprovalRules.Where(rule => !confirmedRuleIds.Contains(rule.RuleId)).ToList(); + var trial = CreateQuotaTrial(context, trialSource, riskResult, pendingApprovalRules); + new Modules.RiskModule.QuotaMonitorService(this).SaveQuotaTrial(trial); + + var result = new RiskTrialResult + { + Blocked = riskResult.Blocked, + NeedApproval = pendingApprovalRules.Any(), + ShowTip = riskResult.ShowTip, + ConfirmationExpired = confirmationExpired, + TrialDataId = trial.id, + Message = trial.RiskWarningDetails, + ApprovalRuleIds = pendingApprovalRules + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList() + }; + result.Passed = !result.Blocked && !result.NeedApproval; + + _logger.Info($"[新风控试算] TradeId: {context.TradeId}, TriggerPoint: {context.TriggerPoint}, TrialSource: {trialSource}, TrialDataId: {result.TrialDataId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, ShowTip: {result.ShowTip}, ConfirmationExpired: {result.ConfirmationExpired}"); + return result; + } + + /// + /// 校验二次确认记录,并恢复首次试算中已确认的审批规则ID。 + /// + private bool TryGetConfirmedRuleIds(RiskContext context, int trialSource, RiskTrialConfirmation confirmation, ISet confirmedRuleIds) + { + if (confirmation.TrialDataId <= 0 || confirmation.ExpireSeconds <= 0) + { + return false; + } + + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == confirmation.TrialDataId); + if (trial == null + || trial.TradeId != context.TradeId + || trial.TrialSource != trialSource + || !trial.OptDate.HasValue + || !HasMatchingRequestContext(trial.RiskWarningDetails, context)) + { + return false; + } + if (DateTime.Now - trial.OptDate.Value > TimeSpan.FromSeconds(confirmation.ExpireSeconds)) + { + return false; + } + + foreach (var ruleId in ParseApprovalRuleIds(trial.RiskWarningDetails)) + { + confirmedRuleIds.Add(ruleId); + } + return confirmedRuleIds.Count > 0; + } + + /// + /// 校验二次提交与首次试算的触发点和平仓日期一致,避免复用其他业务参数生成的审批记录。 + /// + private static bool HasMatchingRequestContext(string details, RiskContext context) + { + var expectedContext = BuildRequestContext(context); + return (details ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Any(line => string.Equals(line, expectedContext, StringComparison.Ordinal)); + } + + /// + /// 从系统生成的审批规则明细中恢复规则ID,避免直接信任前端提交的放行规则列表。 + /// + private static IEnumerable ParseApprovalRuleIds(string details) + { + const string approvalTitle = "[风控引擎] 规则触发:需审批"; + const string ruleIdPrefix = "规则ID:"; + var inApprovalSection = false; + foreach (var line in (details ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("[风控引擎] 规则触发:", StringComparison.Ordinal)) + { + inApprovalSection = line.StartsWith(approvalTitle, StringComparison.Ordinal); + continue; + } + if (!inApprovalSection) + { + continue; + } + + var ruleIdStart = line.IndexOf(ruleIdPrefix, StringComparison.Ordinal); + if (ruleIdStart < 0) + { + continue; + } + ruleIdStart += ruleIdPrefix.Length; + var ruleIdEnd = line.IndexOf(';', ruleIdStart); + var ruleId = (ruleIdEnd < 0 ? line.Substring(ruleIdStart) : line.Substring(ruleIdStart, ruleIdEnd - ruleIdStart)).Trim(); + if (!string.IsNullOrWhiteSpace(ruleId)) + { + yield return ruleId; + } + } + } + + /// + /// 将新风控引擎结果转换为现有试算记录,供详情页面和二次确认复用。 + /// + private QuotaTrial CreateQuotaTrial(RiskContext context, int trialSource, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules) + { + // 业务入口已通过Find加载交易,优先复用当前上下文跟踪的实体,避免重复查询数据库。 + var trade = DbContext.trade.Local.FirstOrDefault(item => item.id == context.TradeId) + ?? DbContext.trade.Find(context.TradeId); + var trial = new QuotaTrial + { + TradeId = context.TradeId, + TradeNumber = trade?.TradeNumber ?? string.Empty, + ClientName = trade?.ClientName ?? string.Empty, + TrialSource = trialSource, + TrialStatus = riskResult.Blocked + ? QuotaTrialStatusEnum.Error + : pendingApprovalRules.Any() ? QuotaTrialStatusEnum.RiskWarning : QuotaTrialStatusEnum.Success, + NewRiskBlocked = riskResult.Blocked, + NewRiskNeedApproval = pendingApprovalRules.Any(), + RiskWarningDetails = BuildRiskDetails(context, riskResult, pendingApprovalRules) + }; + trial.ApprovalRuleIds = pendingApprovalRules + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct() + .ToList(); + return trial; + } + + /// + /// 按控制策略生成与现有试算详情页面兼容的规则明细。 + /// + private static string BuildRiskDetails(RiskContext context, RiskResult riskResult, IReadOnlyCollection pendingApprovalRules) + { + var lines = new List + { + BuildRequestContext(context) + }; + AppendRuleDetails(lines, "禁止", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.Block)); + AppendRuleDetails(lines, "需审批", pendingApprovalRules); + AppendRuleDetails(lines, "提示", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.ShowTip)); + return string.Join("\n", lines); + } + + /// + /// 生成首次试算的业务请求标识,供二次确认严格校验业务参数。 + /// + private static string BuildRequestContext(RiskContext context) + { + return $"[风控引擎] 请求上下文:触发点:{context.TriggerPoint ?? string.Empty};平仓日期:{context.UnwindDate?.Date:yyyy-MM-dd}"; + } + + /// + /// 追加指定控制策略的规则明细。 + /// + private static void AppendRuleDetails(ICollection lines, string strategyName, IEnumerable rules) + { + var ruleList = rules.ToList(); + if (!ruleList.Any()) + { + return; + } + + lines.Add($"[风控引擎] 规则触发:{strategyName}"); + foreach (var rule in ruleList) + { + var detail = $"应用ID:{rule.ApplicationId?.ToString() ?? "-"};规则ID:{rule.RuleId};规则名称:{rule.RuleName};规则说明:{rule.RuleText}"; + if (!string.IsNullOrWhiteSpace(rule.Message)) + { + detail += $";信息:{rule.Message}"; + } + lines.Add(detail); + } + } + } + + /// + /// 新风控二次确认参数。 + /// + public class RiskTrialConfirmation + { + /// + /// 首次风控试算记录ID。 + /// + public int TrialDataId { get; set; } + + /// + /// 首次试算允许确认放行的有效时长,单位为秒。 + /// + public int ExpireSeconds { get; set; } + } + + /// + /// 新风控通用试算结果。 + /// + public class RiskTrialResult + { + public bool Passed { get; set; } + public bool Blocked { get; set; } + public bool NeedApproval { get; set; } + public bool ShowTip { get; set; } + public bool ConfirmationExpired { get; set; } + public int TrialDataId { get; set; } + public string Message { get; set; } + public List ApprovalRuleIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index e2f7c1f4..bc3ac12c 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -6931,6 +6931,40 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + /// + /// 保存平仓风控试算说明,并保留首次试算时间作为二次确认超时起点。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 更新后的平仓风控试算记录。 + public QuotaTrial SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + if (trialDataId <= 0) + { + throw new ServiceException("无效的平仓风控试算记录"); + } + if (string.IsNullOrWhiteSpace(remark)) + { + throw new ServiceException("必须填写说明内容,才可以提交平仓"); + } + + const int closeTrialSource = 2; + var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == trialDataId); + if (trial == null + || trial.TrialSource != closeTrialSource + || trial.TrialStatus != QuotaTrialStatusEnum.RiskWarning) + { + throw new ServiceException("未找到有效的平仓风控审批记录"); + } + + // 仅保存说明和操作人,不更新OptDate,避免重置首次试算的确认超时时间。 + trial.Remark = remark.Trim(); + trial.OptId = UserId; + trial.OptName = UserName; + DbContext.SaveChanges(); + return trial; + } + } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index eb960cc4..45dd3142 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -8,6 +8,7 @@ using YLErp.DBModels.Enums; using YLErp.Helpers; using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; +using YLErp.Modules.RiskEngine; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; @@ -1257,14 +1258,27 @@ namespace YLErp.Modules.SwapModule /// 单标的平仓 /// /// + /// 新风控二次确认信息;首次提交传空。 + /// 本次平仓的新风控试算结果。 /// - public void SwapUnwind(UnwindData unwindData) + public RiskTrialResult SwapUnwind(UnwindData unwindData, RiskTrialConfirmation confirmation = null) { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + var td = FindTrade(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } + // 直接平仓不经过审核提交入口,落库前仍需执行平仓阶段的新风控,避免绕过 CLOSE_REVIEW 规则。 + var riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 @@ -1304,6 +1318,7 @@ namespace YLErp.Modules.SwapModule { TriggerRealtimeSwapPosition(); } + return riskTrialResult; } /// /// 自动全平仓 @@ -1824,19 +1839,36 @@ namespace YLErp.Modules.SwapModule /// /// /// + /// 新风控二次确认信息;首次提交传空。 + /// 平仓事件返回新风控试算结果,互换事件返回空。 /// - public void ApplySwapTrade(UnwindData unwindData, int eventType) + public RiskTrialResult ApplySwapTrade(UnwindData unwindData, int eventType, RiskTrialConfirmation confirmation = null) { + if (unwindData == null) + { + throw new ServiceException("互换或平仓信息不能为空"); + } + var td = FindTrade(unwindData.SwapTradeId); if (td == null) { throw new ServiceException("未找到交易信息"); } + RiskTrialResult riskTrialResult = null; if (eventType == (int)SwapEventTypeEnum.互换) { NormalizeIncomeUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); } + else if (eventType == (int)SwapEventTypeEnum.平仓) + { + // 审核提交保存待审核事件前执行平仓阶段的新风控,需确认时先返回前端,不创建待审核事件。 + riskTrialResult = CheckCloseRisk(unwindData, confirmation); + if (!riskTrialResult.Passed) + { + return riskTrialResult; + } + } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 @@ -1863,7 +1895,34 @@ namespace YLErp.Modules.SwapModule }); } }); + return riskTrialResult; } + + /// + /// 执行互换交易平仓审核时点的新风控试算。 + /// + /// 包含本次平仓日期和交易ID的平仓请求。 + /// 新风控二次确认信息;首次提交传空。 + /// 包含阻断、审批、提示及试算记录ID的新风控结果。 + /// 平仓请求为空。 + private RiskTrialResult CheckCloseRisk(UnwindData unwindData, RiskTrialConfirmation confirmation) + { + if (unwindData == null) + { + throw new ServiceException("平仓信息不能为空"); + } + + const string triggerPoint = "CLOSE_REVIEW"; + const int closeTrialSource = 2; + var riskContext = new RiskContext + { + TradeId = unwindData.SwapTradeId, + TriggerPoint = triggerPoint, + UnwindDate = unwindData.UnwindDate + }; + return new RiskTrialService(this).CheckRisk(riskContext, closeTrialSource, confirmation); + } + private void ValidateIncomeValueDate(UnwindData unwindData, trade td) { var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 717ad13f..0307fff2 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -1,4 +1,4 @@ -using Autofac.Core; +using Autofac.Core; using BaseOUDAL; using CsvHelper; using DocumentFormat.OpenXml.Spreadsheet; @@ -13,6 +13,8 @@ using YLErp.DBModels; using YLErp.DBModels.Consts; using YLErp.Enums; using YLErp.Model.Enum; +using YLErp.Modules.RiskEngine; +using YLErp.Modules.RiskModule; using YLErp.Modules.SalesModule; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Dto; @@ -300,12 +302,13 @@ namespace YLErp.Web.Controllers /// ///单标的 平仓 /// - /// - /// - public JsonResult SwapUnwindJson(UnwindData unwindData) + /// 本次单标的平仓数据。 + /// 首次新风控试算记录ID;二次确认时传入。 + /// 平仓结果或新风控确认信息。 + public JsonResult SwapUnwindJson(UnwindData unwindData, int? trialDataId = null) { - new SwapDealService(CurUser).SwapUnwind(unwindData); - return JsonSuccess("平仓成功"); + var result = new SwapDealService(CurUser).SwapUnwind(unwindData, CreateRiskTrialConfirmation(trialDataId)); + return BuildCloseRiskResult(result, "平仓成功"); } /// ///多空组合 平仓 @@ -340,13 +343,97 @@ namespace YLErp.Web.Controllers /// /// 互换/平仓提交申请 /// - /// - /// - /// - public JsonResult ApplyUnwind(UnwindData unwindData, int eventType) + /// 本次互换或平仓数据。 + /// 事件类型。 + /// 首次新风控试算记录ID;平仓二次确认时传入。 + /// 提交结果或新风控确认信息。 + public JsonResult ApplyUnwind(UnwindData unwindData, int eventType, int? trialDataId = null) { - new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType); - return JsonSuccess("提交成功"); + var result = new SwapDealService(CurUser).ApplySwapTrade(unwindData, eventType, CreateRiskTrialConfirmation(trialDataId)); + return BuildCloseRiskResult(result, "提交成功"); + } + + /// + /// 保存平仓风控特批说明,不改变首次试算时间。 + /// + /// 平仓风控试算记录ID。 + /// 用户填写的特批说明。 + /// 保存结果。 + public JsonResult SaveCloseRiskTrialRemark(int trialDataId, string remark) + { + try + { + var trial = new QuotaMonitorService(CurUser) + .SaveCloseRiskTrialRemark(trialDataId, remark); + new TradeRiskCheckLogService(CurUser).AddLog(trial); + return JsonSuccess(); + } + catch (Exception ex) + { + LogFactory.GetLogger("SaveCloseRiskTrialRemark").Error(ex); + return JsonError("保存平仓风控试算说明失败!"); + } + } + + /// + /// 根据首次试算记录ID创建平仓新风控二次确认参数。 + /// + /// 首次新风控试算记录ID。 + /// 首次提交返回空,二次确认返回包含有效期的确认参数。 + private static RiskTrialConfirmation CreateRiskTrialConfirmation(int? trialDataId) + { + if (!trialDataId.HasValue) + { + return null; + } + + var expireSeconds = 300; + var expireSecondsConfig = AppManager.GetAppConfigValue("ProjectConfig", "Trade.RiskWarningConfirmExpireSeconds"); + if (!string.IsNullOrWhiteSpace(expireSecondsConfig) + && int.TryParse(expireSecondsConfig, out var configuredExpireSeconds) + && configuredExpireSeconds > 0) + { + expireSeconds = configuredExpireSeconds; + } + return new RiskTrialConfirmation + { + TrialDataId = trialDataId.Value, + ExpireSeconds = expireSeconds + }; + } + + /// + /// 将平仓新风控结果转换为控制器统一JSON响应。 + /// + /// 新风控试算结果;互换事件不执行平仓风控时为空。 + /// 业务执行成功后的提示。 + /// 阻断、二次确认或业务成功响应。 + private JsonResult BuildCloseRiskResult(RiskTrialResult result, string successMessage) + { + if (result == null || result.Passed) + { + return JsonSuccess(successMessage, result?.ShowTip == true ? new { result.TrialDataId, result.Message } : null); + } + if (result.Blocked) + { + // 风控阻断属于可预期的业务结果,返回成功协议供前端打开只读试算详情,不进入通用请求失败分支。 + return JsonSuccessData(new + { + proccessType = "QuotaTrialError", + result.TrialDataId, + message = string.IsNullOrWhiteSpace(result.Message) + ? "平仓风控校验未通过" + : result.Message + }); + } + return JsonSuccessData(new + { + proccessType = "AdditionalProcessing", + source = "RiskWarning", + type = "RiskWarningConfirm", + result.TrialDataId, + message = result.ConfirmationExpired ? $"原风控确认已超时,请重新确认。{result.Message}" : result.Message + }); } /// /// 框架合约保存 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index c7fd23ff..20884c3a 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -370,22 +370,72 @@ const vue = new Vue({ postUrl = "/swaptrade2/ApplyUnwind"; postData.eventType = 2;//互换3,平仓2 } - main.confirm(msg, - function () { - //重新计算百分比 - var thisObj2 = thisObj; - main.post(postUrl, postData).done(function (res) { - if (res.success) { - thisObj2.closetrade_cashWindow(); + // 提交平仓并处理新风控阻断、二次确认和成功提示。 + var submitClose = function (trialDataId) { + var requestData = _.cloneDeep(postData); + if (trialDataId) { + requestData.trialDataId = trialDataId; + } + main.post(postUrl, requestData).done(function (res) { + var riskData = res.obj; + if (riskData && riskData.proccessType === "QuotaTrialError") { + // 禁止类规则只展示试算详情,不允许继续提交平仓。 + if (riskData.TrialDataId) { + layer.open({ + type: 2, + title: "风控试算详情", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId, + btn: ["关闭"] + }); } - else { - try { - thisObj2.closetrade_cashWindow(); - } catch (e) { + else if (res.msg) { + main.message(res.msg); + } + return; + } + if (res.success && riskData && riskData.proccessType === "AdditionalProcessing" && riskData.TrialDataId) { + // 审批类规则由用户填写说明并确认,二次提交仅回传后端生成的试算记录ID。 + var riskTrialLayerIndex = layer.open({ + type: 2, + title: "风控试算详情", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + riskData.TrialDataId, + btn: ["交易特批", "取消"], + yes: function (index) { + var trialPage = window["layui-layer-iframe" + index].page; + if (!trialPage.Data.Remark || trialPage.Data.Remark.length <= 0) { + main.message("必须填写说明内容,才可以提交平仓"); + return; + } + // 平仓使用专用接口且只提交记录ID和说明,避免客户端覆盖试算来源或刷新首次试算时间。 + main.post("/SwapTrade2/SaveCloseRiskTrialRemark", { + trialDataId: riskData.TrialDataId, + remark: trialPage.Data.Remark + }).done(function () { + layer.close(riskTrialLayerIndex); + submitClose(riskData.TrialDataId); + }); } + }); + return; + } + if (res.success) { + // 提示类规则不阻断平仓,成功后展示本次风控提示。 + if (riskData && riskData.TrialDataId && riskData.Message) { + main.message(riskData.Message); } - }); + thisObj.closetrade_cashWindow(); + } }); + }; + main.confirm(msg, function () { + submitClose(); + }); }, getSumbitText: function () { return g_isShowReCheckClose ? "审核提交" : "保存";