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/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/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/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/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/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/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");