feat: 收益互换开平仓及收益结算接入OA移动审批

#EQD-6425 国联民生-衍生品系统移动审批(提交OA流程)
- 审批节点支持配置“关联OA移动审批”
- 创建OA流程并记录请求、requestId、状态及异常备注
- 支持OA轮询同步审批通过、退回和归档状态
- 本地通过、拒绝、撤回时同步强制归档未结束OA流程
- 增加审批列表OA备注展示及本地Mock联调接口
- 新增OA审批记录表和数据库升级脚本
- OA移动审批使用独立查询地址及isnextflow配置,不影响交易确认书OA
This commit is contained in:
tengyufan
2026-08-21 15:54:54 +08:00
parent 23a02e06c4
commit 3f0984800a
19 changed files with 784 additions and 9 deletions
@@ -70,5 +70,9 @@ namespace YLErp.DBModels
/// </summary>
[DisplayName("节点触发条件")]
public string triggerCondition { get; set; }
/// <summary>是否在进入该节点时关联国联民生 OA 移动审批。</summary>
public bool isOaApproval { get; set; }
}
}
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace YLErp.DBModels
{
/// <summary>
/// 衍生品交易审批节点对应的国联民生 OA 流程记录。
/// </summary>
[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; }
}
}
+2 -1
View File
@@ -406,6 +406,7 @@ namespace YLErp.BLL
public DbSet<client_margin_config> clientMarginConfig { get; set; }
public DbSet<client_margin_detail> clientMarginDetail { get; set; }
public DbSet<trade_contract_oa_result> tradeContractOaResult { get; set; }
public DbSet<trade_approval_oa_result> tradeApprovalOaResult { get; set; }
public DbSet<BondPayment> bondPayment { get; set; }
public DbSet<glms_risk_rule> glms_risk_rule { get; set; }
@@ -414,4 +415,4 @@ namespace YLErp.BLL
public DbSet<glms_risk_variable> glms_risk_variable { get; set; }
}
}
}
+1
View File
@@ -64,6 +64,7 @@ namespace YLErp.Model
public int? ProcessRoleId { get; set; }
public string ProcessRoleName { get; set; }
public string OaRemark { get; set; }
/// <summary>
/// 验证当前审批角色是否有开仓(交易)复核权限
@@ -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>() { (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.)
@@ -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
/// <summary>节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。</summary>
public string triggerCondition { get; set; }
public bool isOaApproval { get; set; }
}
/// <summary>
/// 审批流程修改节点
@@ -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.;
@@ -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;
}
/// <summary>
@@ -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);
}
/// <summary>
/// 获取包含黑箱结构的子交易集合
/// </summary>
@@ -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
{
/// <summary>
/// 收益互换审批节点的国联民生 OA 对接。
/// 本期只负责 HTTP 创建、查询和归档;审批结果由轮询任务回写本地流程。
/// </summary>
public class TradeApprovalOAService : YLBaseService
{
private const string Pending = "提交成功";
private const string Syncing = "同步中";
private const string Ending = "归档中";
private readonly IYcLogger _logger = LogFactory.GetLogger<TradeApprovalOAService>();
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;
}
/// <summary>
/// 交易首次进入审批时,确保当前 OA 节点已创建对应流程。
/// </summary>
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);
}
/// <summary>
/// 本地撤回前归档交易仍在审批中的 OA 流程。
/// </summary>
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;
}
/// <summary>
/// 本地审批或撤回前归档 OA。通过条件更新抢占记录,避免与轮询回写同时推进同一节点。
/// </summary>
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<string> SyncPendingStatuses(string requestId = null)
{
var messages = new List<string>();
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<string, object> 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<string>
{
"交易日期=" + 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<UnwindData>(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("<br/>", elementLines);
var payload = new Dictionary<string, object>
{
["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<string> values)
{
return string.Join("", values.Where(x => !string.IsNullOrWhiteSpace(x)));
}
}
}
+30
View File
@@ -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);
@@ -120,6 +120,7 @@ namespace YLErp.Modules.TradeModule
// 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。
// 若所有节点均不满足 → 直接审批通过(无需任何人审核)。
ApplyTriggerFromStart(tradeProcess, td, userId);
new TradeApprovalOAService(this).EnsureForCurrentNode(td);
}
/// <summary>
@@ -334,6 +334,9 @@
<span>审批规则</span>
<input :id="'ruleType'+item.Index+item.node" v-model="item.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div v-if="item.Type === 'TradeProcess'">
<label style="margin-left: 14px;"><input type="checkbox" v-model="item.isOaApproval" /> 关联 OA 移动审批</label>
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
@@ -424,7 +427,10 @@
</div>
<div>
<span>审批规则</span>
<input :id="'ruleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
<input :id="'ruleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div v-if="childSecond.Type === 'TradeProcess'">
<label style="margin-left: 14px;"><input type="checkbox" v-model="childSecond.isOaApproval" /> 关联 OA 移动审批</label>
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
@@ -527,6 +533,9 @@
<span>审批规则</span>
<input :id="'closeRuleType'+item.Index+item.node" v-model="item.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div v-if="item.Type === 'CloseProcess'">
<label style="margin-left: 14px;"><input type="checkbox" v-model="item.isOaApproval" /> 关联 OA 移动审批</label>
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>审批条件</span>
@@ -617,7 +626,10 @@
</div>
<div>
<span>审批规则</span>
<input :id="'closeRuleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
<input :id="'closeRuleType'+childSecond.Index+childSecond.node" v-model="childSecond.ApprovalRules" type="text" name="selectRule" data-placeholder="" style="width: 200px; height: 20px" multiple="" />
</div>
<div v-if="childSecond.Type === 'CloseProcess'">
<label style="margin-left: 14px;"><input type="checkbox" v-model="childSecond.isOaApproval" /> 关联 OA 移动审批</label>
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
@@ -0,0 +1,25 @@
using System.Text.Json;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Modules.TradeModule;
namespace YLErp.Web.WebAPI.Controllers
{
/// <summary>国联民生衍生品移动审批轮询入口,由 PowerJob 等外部调度器调用。</summary>
[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 });
}
}
}
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using System.Text.Json;
using YLErp.Helpers;
namespace YLErp.Web.WebAPI.Controllers
{
/// <summary>
/// 仅供本地开发验证收益互换移动审批调用链的 OA 模拟接口。
/// </summary>
[ApiController]
[Route("api/mock/tradeApprovalOa")]
public class TradeApprovalOaMockController : ControllerBase
{
private static readonly ConcurrentDictionary<string, MockOaFlow> 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<object>(), 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; }
}
}
}
+14 -1
View File
@@ -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授权地址
+12 -1
View File
@@ -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登录
@@ -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
});
});
}
@@ -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");
$("#DateFromOptDate").parent().addClass("time-wrap");