支持审批流程中的分支网关和节点触发条件

This commit is contained in:
锦麟 王
2026-07-07 15:47:17 +08:00
parent cf15877bfd
commit fb59c6180a
11 changed files with 2104 additions and 56 deletions
@@ -54,5 +54,21 @@ namespace YLErp.DBModels
/// </summary>
[DisplayName("审批组条件")]
public int? approvalCondition { get; set; }
/// <summary>
/// 分支网关条件(JSON)。
/// <para>用于多分支流程的进入条件判断,结构见 ConditionExpressionConfig。</para>
/// <para>为空时回退到旧的 approvalGroupId/approvalCondition 二元判断(双写兼容)。</para>
/// </summary>
[DisplayName("分支网关条件")]
public string conditionConfig { get; set; }
/// <summary>
/// 节点触发条件(JSON)。
/// <para>仅挂在审批节点(node=0)上:推进到该节点时求值,满足才进入该节点审批;不满足则跳过该节点。</para>
/// <para>为空视为无条件(默认进入审批)。例:名义本金 A默认审核、B>100W再审核、C>=500W再审核。</para>
/// </summary>
[DisplayName("节点触发条件")]
public string triggerCondition { get; set; }
}
}
@@ -0,0 +1,366 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp.Helpers;
namespace YLErp.Helpers.Tests
{
[TestClass]
public class ConditionEvaluatorTests
{
private static ConditionContext CreateContext(double notional = 0, string tradeType = "", int? userId = null, int? initGroupId = null, string processCategory = "")
{
return new ConditionContext
{
Trade = new DBModels.trade
{
StockEqvNotional = notional,
TradeType = tradeType
},
UserId = userId,
InitGroupId = initGroupId,
ProcessCategory = processCategory
};
}
[TestMethod]
public void Evaluate_EmptyOrNullCondition_ReturnsFalse()
{
Assert.IsFalse(ConditionEvaluator.Evaluate(null, CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate("", CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate(" ", CreateContext()));
}
[TestMethod]
public void Evaluate_InvalidJson_ReturnsFalse()
{
Assert.IsFalse(ConditionEvaluator.Evaluate("not a json", CreateContext()));
Assert.IsFalse(ConditionEvaluator.Evaluate("{\"tokens\": [", CreateContext()));
}
[TestMethod]
public void Evaluate_TokenSingleCondition_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken
{
type = "condition",
condition = new ConditionItem { field = "notional", op = ">", value = 100 }
}
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
}
[TestMethod]
public void Evaluate_TokenAnd_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<=", value = 500 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
}
[TestMethod]
public void Evaluate_TokenOr_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 500 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 600)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 300)));
}
[TestMethod]
public void Evaluate_TokenWithParentheses_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "lparen" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
new ConditionToken { type = "rparen" },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
// (200>100 or 200<50) and tradeType=="香草" => true
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
// (30>100 or 30<50) and tradeType=="雪球" => false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "雪球")));
// (80>100 or 80<50) and tradeType=="香草" => false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "香草")));
}
[TestMethod]
public void Evaluate_MixedAndOr_PriorityAndOverOr()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } },
new ConditionToken { type = "operator", connector = "or" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "<", value = 50 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
// A or (B and C) — and 优先级高于 or
// 200>100 -> true,无需计算右侧
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "")));
// 30>100=false, 30<50=true, 香草==香草=true -> false or (true and true) = true
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 30, tradeType: "香草")));
// 80>100=false, 80<50=false, 雪球==香草=false -> false or (false and false) = false
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 80, tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_InitGroup_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "initGroup", op = "==", value = 5 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 5 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { InitGroupId = 3 }));
}
[TestMethod]
public void Evaluate_ProcessCategory_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "processCategory", op = "==", value = "CloseProcess" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "CloseProcess")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(processCategory: "TradeProcess")));
}
[TestMethod]
public void Evaluate_TradeTypeStringComparison_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "!=", value = "雪球" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_IncompleteParentheses_DoesNotThrow()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "lparen" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">", value = 100 } }
}
});
// Parser tolerates missing closing parenthesis and returns the value inside
var result = ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200));
Assert.IsTrue(result);
}
[TestMethod]
public void Evaluate_NotEquals_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "!=", value = 100 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
}
[TestMethod]
public void Evaluate_GreaterOrEqual_Works()
{
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = ">=", value = 100 } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
}
// ===== 字母 op 标识符(前端改用 gt/lt/gte/lte/eq/neq 规避 > < 编码问题)=====
[TestMethod]
public void Evaluate_AlphaOp_GreaterThan_Works()
{
var condition = BuildTokens(new ConditionItem { field = "notional", op = "gt", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
}
[TestMethod]
public void Evaluate_AlphaOp_LessThanOrEqual_Works()
{
var condition = BuildTokens(new ConditionItem { field = "notional", op = "lte", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 101)));
}
[TestMethod]
public void Evaluate_AlphaOp_EqualAndNotEqual_Works()
{
var eqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "eq", value = "香草" });
Assert.IsTrue(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(eqCond, CreateContext(tradeType: "雪球")));
var neqCond = BuildTokens(new ConditionItem { field = "tradeType", op = "neq", value = "雪球" });
Assert.IsTrue(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(neqCond, CreateContext(tradeType: "雪球")));
}
[TestMethod]
public void Evaluate_AlphaOp_CaseInsensitive_Works()
{
// 大写字母 op 也应识别(归一化为小写)
var condition = BuildTokens(new ConditionItem { field = "notional", op = "GTE", value = 100 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 100)));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 99)));
}
[TestMethod]
public void Evaluate_MixedAlphaAndSymbolOp_Works()
{
// 字母 op 与符号 op 混用:notional gt 100 and tradeType == 香草
var condition = JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = new ConditionItem { field = "notional", op = "gt", value = 100 } },
new ConditionToken { type = "operator", connector = "and" },
new ConditionToken { type = "condition", condition = new ConditionItem { field = "tradeType", op = "==", value = "香草" } }
}
});
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 50, tradeType: "香草")));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, CreateContext(notional: 200, tradeType: "雪球")));
}
// ===== 需求①触发条件字段:initialNotional(期初)/ currentNotional(本次)=====
[TestMethod]
public void Evaluate_InitialNotional_UsesOriginalStockEqvNotional()
{
// initialNotional 取 trade.OriginalStockEqvNotional(与 StockEqvNotional 是不同字段)
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gte", value = 1000000 });
var ctx = new ConditionContext
{
Trade = new DBModels.trade
{
StockEqvNotional = 500, // 当前份额,不应被 initialNotional 使用
OriginalStockEqvNotional = 2000000 // 期初名义本金
}
};
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
var ctxBelow = new ConditionContext
{
Trade = new DBModels.trade { OriginalStockEqvNotional = 500000 }
};
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, ctxBelow));
}
[TestMethod]
public void Evaluate_CurrentNotional_UsesContextValue()
{
// currentNotional 取 ConditionContext.CurrentNotional(了结场景由 trade_cash 取绝对值传入)
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gt", value = 1000000 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 500000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = null }));
}
[TestMethod]
public void Evaluate_CurrentNotional_AbsoluteValueSemantics()
{
// 需求:平仓500万,本次交易名义本金按绝对值判断。
// 调用方应传 Math.Abs 后的正值(BuildTriggerContext 已处理),这里验证传入正值即可。
var condition = BuildTokens(new ConditionItem { field = "currentNotional", op = "gte", value = 5000000 });
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 5000000 }));
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 8000000 }));
Assert.IsFalse(ConditionEvaluator.Evaluate(condition, new ConditionContext { CurrentNotional = 4999999 }));
}
[TestMethod]
public void Evaluate_InitialNotional_AbsoluteValueStored()
{
// OriginalStockEqvNotional 的 setter 已做 Math.Abs,负值存入会变正
var condition = BuildTokens(new ConditionItem { field = "initialNotional", op = "gt", value = 100 });
var ctx = new ConditionContext
{
Trade = new DBModels.trade { OriginalStockEqvNotional = -500 } // setter 归一化为 500
};
Assert.IsTrue(ConditionEvaluator.Evaluate(condition, ctx));
}
/// <summary>辅助:单条件 tokens 序列化为 JSON。</summary>
private static string BuildTokens(ConditionItem item)
{
return JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new System.Collections.Generic.List<ConditionToken>
{
new ConditionToken { type = "condition", condition = item }
}
});
}
}
}
@@ -0,0 +1,162 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.Helpers;
using System.Collections.Generic;
using System.Linq;
namespace YLErp.Helpers.Tests
{
/// <summary>
/// 需求①:交易提交进入审批流程时的「起点触发条件跳过」测试。
/// <para>场景:交易一进来,若节点1、2配置的触发条件均不满足,应直接从节点3开始审核;
/// 若全部节点都不满足,则直接审批通过(无需任何审核)。</para>
/// </summary>
[TestClass]
public class TriggerNodeSkipTests
{
/// <summary>构造一个审批节点:order + 可选的触发条件JSON。</summary>
private static approvalprocess Node(int order, string triggerCondition = null)
{
return new approvalprocess
{
order = order,
node = 0,
triggerCondition = triggerCondition
};
}
/// <summary>构造"期初名义本金 > 阈值"的触发条件JSON。</summary>
private static string InitialNotionalGt(double threshold)
{
return JsonConvert.SerializeObject(new ConditionExpressionConfig
{
tokens = new List<ConditionToken>
{
new ConditionToken
{
type = "condition",
condition = new ConditionItem { field = "initialNotional", op = "gt", value = threshold }
}
}
});
}
private static ConditionContext Ctx(double initialNotional)
{
return new ConditionContext
{
Trade = new trade { OriginalStockEqvNotional = initialNotional }
};
}
[TestMethod]
public void StartNode_NoTrigger_ReturnsStartDirectly()
{
// 节点1无触发条件 → 直接返回节点1
var nodes = new List<approvalprocess> { Node(1), Node(2), Node(3) };
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500));
Assert.IsNotNull(result);
Assert.AreEqual(1, result.order);
}
[TestMethod]
public void StartNode_TriggerSatisfied_ReturnsStart()
{
// 节点1触发条件">100",交易期初200满足 → 返回节点1
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(100)),
Node(2),
Node(3)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(1, result.order);
}
[TestMethod]
public void StartNode_TriggerNotSatisfied_SkipsToNextSatisfied()
{
// 节点1触发">100",节点2触发">500",交易期初200
// → 节点1不满足(200>100满足? 满足)... 重新设计:节点1触发">1000"200不满足;节点2无触发 → 返回节点2
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足 >1000
Node(2), // 无触发条件
Node(3)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(2, result.order);
}
[TestMethod]
public void AllStartNodesNotSatisfied_ReturnsNull_DirectlyApproved()
{
// 节点1、2、3都有触发条件,交易都不满足 → 返回null(表示直接审批通过)
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足
Node(2, InitialNotionalGt(5000)), // 200 不满足
Node(3, InitialNotionalGt(10000)) // 200 不满足
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNull(result);
}
[TestMethod]
public void SkipMultipleNodes_LandsOnThird()
{
// 节点1、2都不满足,节点3无触发 → 返回节点3
// 模拟"A默认审核、B>100W再审核、C>=500W再审核"中,小额交易跳过B、C直达... 实际应停在满足的节点
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000000)), // 50W 不满足
Node(2, InitialNotionalGt(2000000)), // 50W 不满足
Node(3) // 无触发条件(兜底审核)
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(500000));
Assert.IsNotNull(result);
Assert.AreEqual(3, result.order);
}
[TestMethod]
public void SatisfiedAtSecondNode_StopsThere()
{
// 节点1触发">1000"不满足,节点2触发">100"满足 → 返回节点2(不会继续到节点3)
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)), // 200 不满足
Node(2, InitialNotionalGt(100)), // 200 满足
Node(3, InitialNotionalGt(50)) // 不会走到这
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[0], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(2, result.order);
}
[TestMethod]
public void StartFromMiddleNode_Works()
{
// 起点不是节点1(如分支调整后从节点2开始),从节点2起判断
var nodes = new List<approvalprocess>
{
Node(1, InitialNotionalGt(1000)),
Node(2, InitialNotionalGt(1000)), // 200 不满足
Node(3) // 无触发
};
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, nodes[1], Ctx(200));
Assert.IsNotNull(result);
Assert.AreEqual(3, result.order);
}
[TestMethod]
public void NullStart_ReturnsNull()
{
var nodes = new List<approvalprocess> { Node(1) };
var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, null, Ctx(500));
Assert.IsNull(result);
}
}
}
+348
View File
@@ -0,0 +1,348 @@
using BaseOUDAL;
using Newtonsoft.Json;
namespace YLErp.Helpers
{
/// <summary>
/// 审批条件求值器:统一支撑「节点触发条件」与「分支网关条件」。
/// <para>需求①(节点触发)与需求③(多分支)共用同一套条件模型,避免两套条件语义。</para>
/// <para>支持混合「且/或」与「括号」的布尔表达式,如 A and (B or C)。</para>
/// </summary>
public static class ConditionEvaluator
{
/// <summary>
/// 求值条件 JSON。
/// </summary>
/// <param name="conditionJson">条件 JSON(结构见 ConditionExpressionConfig);为空/null 视为无条件,返回 false(不触发)。</param>
/// <param name="context">业务上下文(交易/发起人等)。</param>
/// <returns>是否满足条件</returns>
public static bool Evaluate(string conditionJson, ConditionContext context)
{
if (string.IsNullOrWhiteSpace(conditionJson))
{
return false;
}
ConditionExpressionConfig config;
try
{
config = JsonConvert.DeserializeObject<ConditionExpressionConfig>(conditionJson);
}
catch
{
// 容错:非法 JSON 不阻断审批主流程,视为不触发。
return false;
}
if (config == null || config.tokens == null || config.tokens.Count == 0)
{
return false;
}
// 递归下降求值(支持括号、且/或混合)。
var parser = new ConditionParser(config.tokens, context);
return parser.Parse();
}
/// <summary>
/// 从起点节点开始,向后查找第一个满足触发条件(或无触发条件)的审批节点(需求①)。
/// <para>用于交易提交进入审批流程时确定起始审批节点:若起点节点配置了触发条件且当前业务不满足,
/// 则跳过该节点继续向后找,直到找到可进入的节点;若从起点到末尾均不满足则返回 null(表示无需审批,直接通过)。</para>
/// </summary>
/// <param name="tradeProcess">流程全部节点(已按 order 排序)</param>
/// <param name="start">起点节点</param>
/// <param name="ctx">条件求值上下文</param>
/// <returns>第一个应进入审批的节点;若无需审批则返回 null</returns>
public static approvalprocess FindFirstTriggeredNode(
List<approvalprocess> tradeProcess,
approvalprocess start,
ConditionContext ctx)
{
if (start == null) return null;
var current = start;
while (current != null)
{
// 无触发条件,或满足触发条件 → 该节点需审批
if (string.IsNullOrWhiteSpace(current.triggerCondition)
|| Evaluate(current.triggerCondition, ctx))
{
return current;
}
// 不满足 → 向后取下一个主干节点(node=0)
current = tradeProcess.FirstOrDefault(x => x.order > current.order && x.node == 0);
}
return null;
}
/// <summary>求值单个条件。</summary>
internal static bool EvaluateSingle(ConditionItem cond, ConditionContext context)
{
if (cond == null || string.IsNullOrEmpty(cond.field) || string.IsNullOrEmpty(cond.op))
{
return false;
}
var leftValue = FieldResolver.ResolveValue(cond.field, context);
return OperatorCompare(leftValue, cond.op, cond.value);
}
/// <summary>比较:能转数值时按数值比,否则按字符串比。</summary>
private static bool OperatorCompare(object left, string op, object right)
{
if (TryToDouble(left, out var ld) && TryToDouble(right, out var rd))
{
return CompareNumeric(ld, op, rd);
}
var ls = left?.ToString() ?? string.Empty;
var rs = right?.ToString() ?? string.Empty;
return CompareString(ls, op, rs);
}
private static bool CompareNumeric(double left, string op, double right)
{
return NormalizeOp(op) switch
{
">" => left > right,
"<" => left < right,
">=" => left >= right,
"<=" => left <= right,
"==" => Math.Abs(left - right) < 1e-9,
"!=" => Math.Abs(left - right) >= 1e-9,
_ => false
};
}
private static bool CompareString(string left, string op, string right)
{
return NormalizeOp(op) switch
{
"==" => left == right,
"!=" => left != right,
">" => string.Compare(left, right, StringComparison.Ordinal) > 0,
"<" => string.Compare(left, right, StringComparison.Ordinal) < 0,
">=" => string.Compare(left, right, StringComparison.Ordinal) >= 0,
"<=" => string.Compare(left, right, StringComparison.Ordinal) <= 0,
"in" => right.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Any(r => string.Equals(r.Trim(), left, StringComparison.OrdinalIgnoreCase)),
_ => false
};
}
/// <summary>
/// 归一化操作符:兼容字母标识符(gt/lt/gte/lte/eq/neq)与符号(> < >= <= == != =)。
/// </summary>
private static string NormalizeOp(string op)
{
if (string.IsNullOrEmpty(op)) return op;
return op.ToLowerInvariant() switch
{
"gt" or ">" => ">",
"lt" or "<" => "<",
"gte" or ">=" => ">=",
"lte" or "<=" => "<=",
"eq" or "==" or "=" => "==",
"neq" or "!=" or "<>" => "!=",
_ => op
};
}
private static bool TryToDouble(object value, out double result)
{
result = 0;
if (value == null) return false;
return double.TryParse(value.ToString(), out result);
}
}
/// <summary>
/// 递归下降解析器:按 token 顺序求值布尔表达式,支持括号与「且/或」优先级。
/// <para>文法:Expr := Term (("and"|"or") Term)* Term := condition | "(" Expr ")"。</para>
/// <para>优先级:and 高于 or(与常规布尔代数一致);同级从左到右。</para>
/// </summary>
internal class ConditionParser
{
private readonly List<ConditionToken> _tokens;
private readonly ConditionContext _context;
private int _pos;
public ConditionParser(List<ConditionToken> tokens, ConditionContext context)
{
_tokens = tokens ?? new List<ConditionToken>();
_context = context;
_pos = 0;
}
public bool Parse()
{
if (_tokens.Count == 0) return false;
return ParseOr();
}
// 低优先级:ParseOr := ParseAnd ( "or" ParseAnd )* 左结合,遇 or 短路(为 true 直接返回后续不再求值)
private bool ParseOr()
{
var left = ParseAnd();
while (true)
{
var op = Peek();
if (op == null || op.type != "operator" || !IsOr(op.connector)) break;
_pos++; // 消费 or
var right = ParseAnd();
left = left || right;
}
return left;
}
// 高优先级:ParseAnd := ParseTerm ( "and" ParseTerm )* 左结合,遇 and 短路(为 false 直接返回)
private bool ParseAnd()
{
var left = ParseTerm();
while (true)
{
var op = Peek();
if (op == null || op.type != "operator" || IsOr(op.connector)) break;
_pos++; // 消费 and
var right = ParseTerm();
left = left && right;
}
return left;
}
// Term := condition | "(" ParseOr ")"
private bool ParseTerm()
{
var tok = Peek();
if (tok == null) return false;
if (tok.type == "lparen")
{
_pos++; // 消费 "("
var val = ParseOr();
var rp = Peek();
if (rp != null && rp.type == "rparen") _pos++; // 消费 ")"
return val;
}
if (tok.type == "condition")
{
_pos++;
return ConditionEvaluator.EvaluateSingle(tok.condition, _context);
}
return false;
}
private ConditionToken Peek() => _pos < _tokens.Count ? _tokens[_pos] : null;
private static bool IsOr(string connector)
=> string.Equals(connector, "or", StringComparison.OrdinalIgnoreCase);
}
/// <summary>条件业务上下文:求值时由调用方构造,封装可参与判断的业务字段。</summary>
public class ConditionContext
{
/// <summary>发起人 userId(用于查发起人审批组)。</summary>
public int? UserId { get; set; }
/// <summary>交易实体(期初名义本金等字段来源)。开户场景可为 null。</summary>
public trade Trade { get; set; }
/// <summary>交易流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②用。</summary>
public string ProcessCategory { get; set; }
/// <summary>预解析的发起人审批组(避免重复查库;为空时由 FieldResolver 查)。</summary>
public int? InitGroupId { get; set; }
/// <summary>本次交易名义本金的取值(了结场景由调用方从 trade_cash.UnwindStockEqvNotional 取绝对值传入)。需求①。</summary>
public double? CurrentNotional { get; set; }
}
/// <summary>
/// 条件表达式配置(对应 conditionConfig / triggerCondition 列的 JSON 结构)。
/// <para>tokenstoken 序列,支持「且/或」混合与括号,按表达式顺序排列。</para>
/// </summary>
public class ConditionExpressionConfig
{
/// <summary>token 序列:条件 / 且或连接符 / 左右括号,按表达式顺序排列。</summary>
public List<ConditionToken> tokens { get; set; }
}
/// <summary>
/// 表达式 token:一个条件、一个连接符、或一个括号。
/// </summary>
public class ConditionToken
{
/// <summary>token 类型:condition | operator | lparen | rparen</summary>
public string type { get; set; }
/// <summary>当 type=condition 时的条件体。</summary>
public ConditionItem condition { get; set; }
/// <summary>当 type=operator 时的连接符:and | or</summary>
public string connector { get; set; }
}
/// <summary>单个条件:左值字段 + 操作符 + 右值。</summary>
public class ConditionItem
{
/// <summary>左值字段 key,见 FieldResolverinitGroup/notional/tradeType 等)。</summary>
public string field { get; set; }
/// <summary>操作符:> &lt; &gt;= &lt;= == != = in</summary>
public string op { get; set; }
/// <summary>右值</summary>
public object value { get; set; }
}
/// <summary>
/// 条件左值解析:把 field key 映射到具体业务字段值。
/// <para>触发条件字段(需求①):</para>
/// <para>- initialNotional:交易的期初名义本金(trade.OriginalStockEqvNotional,已取绝对值)</para>
/// <para>- currentNotional :本次交易名义本金(了结场景,由调用方从 trade_cash 取本次影响金额绝对值传入)</para>
/// <para>历史兼容:initGroup/tradeType/processCategory/notional 仍可解析。</para>
/// </summary>
public static class FieldResolver
{
public static object ResolveValue(string field, ConditionContext context)
{
if (string.IsNullOrEmpty(field) || context == null)
{
return null;
}
switch (field.ToLowerInvariant())
{
// 需求①:触发条件字段(仅这两个对外暴露)
case "initialnotional":
// 交易的期初名义本金
return context.Trade?.OriginalStockEqvNotional ?? 0;
case "currentnotional":
// 本次交易名义本金(了结时按本次影响金额绝对值,由调用方传入)
return context.CurrentNotional ?? 0;
// 以下为历史兼容,前端不再暴露
case "notional":
return context.Trade?.StockEqvNotional ?? 0;
case "initgroup":
if (context.InitGroupId.HasValue)
{
return context.InitGroupId.Value;
}
return context.UserId.HasValue
? UserBLL.GetApprovalProcessGroup(context.UserId.Value)
: 0;
case "tradetype":
return context.Trade?.TradeType ?? string.Empty;
case "processcategory":
return context.ProcessCategory ?? string.Empty;
default:
return null;
}
}
}
}
@@ -6,7 +6,9 @@ using System.Linq;
using System.Linq.Expressions;
using YLErp.BLL.Eod;
using YLErp.DBModels;
using YLErp.Helpers;
using YLErp.Model.Enum;
using YLErp.Modules.TradeModule;
namespace YLErp.Modules.SystemModule
{
@@ -43,7 +45,9 @@ namespace YLErp.Modules.SystemModule
approvalGroupId = item.approvalGroupId,
node = item.node,
parentNode = item.parentNode,
approvalCondition = item.approvalCondition
approvalCondition = item.approvalCondition,
conditionConfig = item.conditionConfig,
triggerCondition = item.triggerCondition
}).ToList();
DbContext.approvalprocess.AddRange(list);
@@ -81,6 +85,21 @@ namespace YLErp.Modules.SystemModule
}
}
}
else if (type == ProcessCategoryConst.Close) // 需求②:了结/平仓/行权审批流程
{
if (data != null && data.Count > 0)
{
ChangeTradeProcess(data, delList);
}
else
{
var trade = DbContext.trade.Where(x => x.ValidState == "Valid" && (x.TradeStatus == ConsTrade. || x.TradeStatus == ConsTrade. || x.TradeStatus == ConsTrade.)).ToList();
if (trade != null && trade.Count > 0)
{
throw new ServiceException("有交易在审批中,不能删除审批流程!");
}
}
}
else if (type == "CreditProcess")
{
if (data != null && data.Count == 0)
@@ -578,7 +597,8 @@ namespace YLErp.Modules.SystemModule
{
var roles = UserBLL.GetRolesByUserId(userId).Select(o => o.Id);
var groupId = UserBLL.GetApprovalProcessGroup(userId);
var tradeProcess = TradeProcess();
// 需求②:按交易状态推断开仓/了结流程。
var tradeProcess = TradeProcessByCategory(trade);
bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况
trade.ProcessOrderBranch = 0;
if (trade.ProcessOrderId <= 0)
@@ -612,6 +632,16 @@ namespace YLErp.Modules.SystemModule
trade.ProcessOrderId = ProcessTradeLog.;
}
}
// 需求①:开仓交易首次进入审批时,应用触发条件——跳过起始就不满足触发条件的节点。
// 若所有节点均不满足 → 直接审批通过,无需审核。
if (tradeProcess.Count > 0)
{
ApplyTriggerOnStart(tradeProcess, trade, userId);
if (trade.ProcessOrderId == ProcessTradeLog.)
{
return 0;
}
}
// 如果是审批组
var orderIdCount = tradeProcess.Where(x => x.order == trade.ProcessOrderId);//判断是否有分支
int processOrderNode = orderIdCount.Count() > 1 ? trade.ProcessOrderBranch : 0;
@@ -658,6 +688,65 @@ namespace YLErp.Modules.SystemModule
return -2;
}
/// <summary>
/// 需求①:开仓交易首次进入审批流程时,从起始节点应用触发条件。
/// <para>跳过起始就不满足触发条件的节点;若所有节点均不满足 → 直接审批通过。</para>
/// </summary>
private void ApplyTriggerOnStart(List<approvalprocess> tradeProcess, trade trade, int userId)
{
// 取当前起点节点(主干 node=0)
var start = tradeProcess.FirstOrDefault(x => x.order == trade.ProcessOrderId && x.node == 0);
if (start == null)
{
start = tradeProcess.FirstOrDefault(x => x.order >= trade.ProcessOrderId && x.node == 0);
}
if (start == null) return;
// 构建求值上下文(开仓场景无 trade_cashCurrentNotional 不设)
var ctx = new ConditionContext
{
UserId = userId,
Trade = trade,
ProcessCategory = ProcessCategoryConst.Resolve(trade),
InitGroupId = UserBLL.GetApprovalProcessGroup(userId)
};
// ===== 临时诊断日志(定位触发条件是否生效,验证后删除)=====
try
{
var tn = trade?.TradeNumber ?? "?";
var initNotional = trade?.OriginalStockEqvNotional ?? 0;
System.Diagnostics.Debug.WriteLine($"[ApplyTriggerOnStart] trade={tn}, 期初名义本金={initNotional}, 起点order={start.order}, 起点triggerCondition={start.triggerCondition ?? "()"}, 节点数={tradeProcess.Count}");
foreach (var n in tradeProcess)
{
System.Diagnostics.Debug.WriteLine($" 节点 order={n.order}, node={n.node}, roleId={n.roleId}, triggerCondition={n.triggerCondition ?? "()"}");
}
}
catch { }
var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, start, ctx);
try
{
System.Diagnostics.Debug.WriteLine($"[ApplyTriggerOnStart] FindFirstTriggeredNode 结果 = {(target == null ? "null(,)" : "order=" + target.order)}");
}
catch { }
if (target == null)
{
// 所有节点都不满足触发条件 → 直接审批通过
trade.ProcessOrderId = ProcessTradeLog.;
trade.CheckTradeUpdate = Convert.ToInt32(TradeCheckEnum.StatusOfNew);
trade.ProcessOptDate = DateTime.Now;
trade.ProcessStatus = ProcessTradeStatus..ToString();
return;
}
if (target.order != trade.ProcessOrderId)
{
trade.ProcessOrderId = target.order;
}
}
/// <summary>
/// 获取所有交易审批节点
/// </summary>
@@ -668,6 +757,21 @@ namespace YLErp.Modules.SystemModule
return tradeOrders;
}
/// <summary>
/// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。
/// <para>了结类优先取 CloseProcess;未配置则回退 TradeProcess。</para>
/// </summary>
public List<approvalprocess> TradeProcessByCategory(trade td)
{
var category = ProcessCategoryConst.Resolve(td);
var orders = DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList();
if (category == ProcessCategoryConst.Close && orders.Count == 0)
{
return TradeProcess();
}
return orders;
}
}
/// <summary>
@@ -686,6 +790,12 @@ namespace YLErp.Modules.SystemModule
public int node { get; set; }
public int parentNode { get; set; }
public int approvalCondition { get; set; }
/// <summary>分支网关条件(JSON),需求①③共用。为空则回退旧 approvalGroupId/approvalCondition 二元判断。</summary>
public string conditionConfig { get; set; }
/// <summary>节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。</summary>
public string triggerCondition { get; set; }
}
/// <summary>
/// 审批流程修改节点
@@ -1,6 +1,7 @@
using BaseOUDAL;
using YLErp.BLL;
using YLErp.BLL.Eod;
using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.ClientModule;
@@ -140,7 +141,8 @@ namespace YLErp.Modules.TradeModule.DealModule
return result;
}
//TODO 如果是审批组
var tradeProessQuery = TradeProcess();
// 需求②:按交易状态推断开仓/了结流程。了结类(平仓/行权/互换待复核)走 CloseProcess。
var tradeProessQuery = TradeProcessByCategory(td);
var count = tradeProessQuery.Count();
if (count == 0 || td.ProcessOrderId == ProcessTradeLog.)//投资规模校验已经将数据设置为已通过
{
@@ -166,6 +168,8 @@ namespace YLErp.Modules.TradeModule.DealModule
//{
// td.ProcessOrderBranch = 0;
//}
// 节点触发条件(需求①):下一节点配置了 triggerCondition 时,满足才进入审批,不满足则跳过。
nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId));
if (nextOrder==null)
{
@@ -225,7 +229,8 @@ namespace YLErp.Modules.TradeModule.DealModule
var childrenTrades = DbContext.trade.Where(x => childrenTradeIds.Contains(x.id)).ToList();
var result = new TradeOpenResult(td);
// 如果是审批组
var tradeProessQuery = TradeProcess();
// 需求②:分组了结按交易状态推断开仓/了结流程。
var tradeProessQuery = TradeProcessByCategory(td);
var count = tradeProessQuery.Count();
if (count == 0)
{
@@ -246,6 +251,8 @@ namespace YLErp.Modules.TradeModule.DealModule
{
nextOrder = tradeProessQuery.FirstOrDefault(x => x.order > td.ProcessOrderId && x.node == td.ProcessOrderBranch && x.approvalGroupId == 0);
}
// 节点触发条件(需求①):分组了结推进同样支持触发条件。
nextOrder = AdvanceThroughTriggerNodes(tradeProessQuery, nextOrder, BuildTriggerContext(td, UserId));
if (nextOrder == null)
{
SetParentTradeOpen(req, td, childrenTrades, parentTradeCash);
@@ -2,6 +2,7 @@
using YLErp.BLL;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
using YLErp.Helpers;
namespace YLErp.Modules.TradeModule
{
@@ -46,14 +47,38 @@ namespace YLErp.Modules.TradeModule
return _tradeProcessCount.Value;
}
/// <summary>
/// 获取所有交易审批节点
/// 获取所有交易审批节点(开仓流程 TradeProcess)。
/// </summary>
/// <returns></returns>
public List<approvalprocess> TradeProcess()
{
var tradeOrders = DbContext.approvalprocess.Where(t => t.processType == "TradeProcess").OrderBy(o => o.order).ToList();
return tradeOrders;
}
/// <summary>
/// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。
/// <para>了结类(平仓待复核/行权待复核/互换待复核)优先取 CloseProcess;若未配置则回退 TradeProcess,避免卡单。</para>
/// </summary>
public List<approvalprocess> TradeProcessByCategory(trade td)
{
var category = ResolveProcessCategory(td);
var orders = DbContext.approvalprocess.Where(t => t.processType == category).OrderBy(o => o.order).ToList();
// 兼容回退:了结流程未配置时,回退到开仓流程。
if (category == ProcessCategoryConst.Close && orders.Count == 0)
{
return TradeProcess();
}
return orders;
}
/// <summary>
/// 按类别统计审批节点数(需求②)。
/// </summary>
public int TradeProcessCountByCategory(trade td)
{
return TradeProcessByCategory(td).Count;
}
/// <summary>
/// 初始化交易 审批点
/// </summary>
@@ -62,7 +87,7 @@ namespace YLErp.Modules.TradeModule
{
td.ProcessOrderId = ProcessTradeLog.;
td.ProcessStatus = ProcessTradeStatus..ToString();
var tradeProcess = TradeProcess();
var tradeProcess = TradeProcessByCategory(td);
var groupId = UserBLL.GetApprovalProcessGroup(userId);
bool approvalBranch = tradeProcess.Any(x => x.approvalGroupId != 0);//审批流程有分支情况
if (td.ProcessOrderId == 1)
@@ -88,6 +113,111 @@ namespace YLErp.Modules.TradeModule
{
td.ProcessOrderId = 2;
}
// 需求①:进入审批流程时即应用触发条件——从起始节点开始,跳过所有不满足触发条件的节点。
// 若所有节点均不满足 → 直接审批通过(无需任何人审核)。
ApplyTriggerFromStart(tradeProcess, td, userId);
}
/// <summary>
/// 从当前 ProcessOrderId 起点开始,按触发条件跳过无需审批的节点(需求①)。
/// <para>场景:交易提交进入审批流程时,若节点1、2配置的触发条件均不满足,则直接跳到节点3;
/// 若全部节点都不满足,则直接审批通过。</para>
/// </summary>
private void ApplyTriggerFromStart(List<approvalprocess> tradeProcess, trade td, int userId)
{
if (tradeProcess == null || tradeProcess.Count == 0) return;
// 取当前起点节点(主干 node=0)
var current = tradeProcess.FirstOrDefault(x => x.order == td.ProcessOrderId && x.node == 0);
// 若起点不在主干(如分支调整后 ProcessOrderId=2),取该 order 的主干节点
if (current == null)
{
current = tradeProcess.FirstOrDefault(x => x.order >= td.ProcessOrderId && x.node == 0);
}
if (current == null) return;
var ctx = BuildTriggerContext(td, userId);
var target = ConditionEvaluator.FindFirstTriggeredNode(tradeProcess, current, ctx);
if (target == null)
{
// 所有节点都不满足触发条件 → 直接审批通过,无需审核
td.ProcessOrderId = ProcessTradeLog.;
td.ProcessStatus = ProcessTradeStatus..ToString();
td.ProcessOptDate = DateTime.Now;
return;
}
// target 即为第一个满足触发条件、需实际审批的节点
if (target.order != td.ProcessOrderId)
{
td.ProcessOrderId = target.order;
}
}
/// <summary>
/// 节点触发条件(需求①):在已确定下一审批节点 nextOrder 后,若该节点配置了 triggerCondition
/// 则只有「满足触发条件」才进入该节点审批;不满足则跳过该节点,继续向后寻找,直到找到可进入的节点或抵达流程末尾。
/// <para>语义:triggerCondition 为空 → 无条件进入审批;非空 → 满足才进入,不满足则跳过。</para>
/// <para>非侵入式:原有分支推进逻辑不变,仅在其结果之上叠加触发判断循环。</para>
/// </summary>
/// <param name="tradeProcess">当前流程的全部节点(已按 order 排序)</param>
/// <param name="nextOrder">原逻辑计算出的下一节点(可能为 null)</param>
/// <param name="ctx">条件求值业务上下文(已含 trade、本次交易名义本金等)</param>
/// <returns>最终应推进到的节点;若应结束流程则返回 null</returns>
protected static approvalprocess AdvanceThroughTriggerNodes(
List<approvalprocess> tradeProcess,
approvalprocess nextOrder,
ConditionContext ctx)
{
// 不满足触发条件的节点需跳过:循环向后找第一个可进入的节点。
while (nextOrder != null && !string.IsNullOrWhiteSpace(nextOrder.triggerCondition))
{
if (ConditionEvaluator.Evaluate(nextOrder.triggerCondition, ctx))
{
break; // 满足触发条件 → 进入该节点审批,停止跳过。
}
// 不满足触发条件 → 跳过该节点,向后取下一个主干节点(node=0),继续判断。
nextOrder = tradeProcess.FirstOrDefault(x => x.order > nextOrder.order && x.node == 0);
}
return nextOrder;
}
/// <summary>
/// 构建触发条件求值上下文:从 trade 及其关联的 trade_cash 取本次交易名义本金(了结场景)。
/// <para>本次交易名义本金 = 本次了结操作的 trade_cash.UnwindStockEqvNotional 绝对值。</para>
/// </summary>
protected ConditionContext BuildTriggerContext(trade td, int userId)
{
var ctx = new ConditionContext
{
UserId = userId,
Trade = td,
ProcessCategory = ResolveProcessCategory(td),
InitGroupId = UserBLL.GetApprovalProcessGroup(userId)
};
// 了结场景:取本次影响的名义本金(trade_cash.UnwindStockEqvNotional 绝对值)
if (td != null && ctx.ProcessCategory == ProcessCategoryConst.Close)
{
var tc = DbContext.trade_cash
.Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted)
.OrderByDescending(t => t.id)
.FirstOrDefault();
if (tc != null && tc.UnwindStockEqvNotional.HasValue)
{
ctx.CurrentNotional = Math.Abs(tc.UnwindStockEqvNotional.Value);
}
}
return ctx;
}
/// <summary>
/// 按交易状态推断流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②。
/// <para>委托给 ProcessCategoryConst.Resolve,供全局复用。</para>
/// </summary>
protected static string ResolveProcessCategory(trade td)
{
return ProcessCategoryConst.Resolve(td);
}
/// <summary>
/// 添加交易操作日志
@@ -521,4 +651,31 @@ namespace YLErp.Modules.TradeModule
return rateCalcModeValue;
}
}
/// <summary>
/// 交易流程类别常量(需求②):对应 approvalprocess.processType 的取值。
/// </summary>
public static class ProcessCategoryConst
{
/// <summary>开仓审批流程</summary>
public const string Open = "TradeProcess";
/// <summary>了结/平仓/行权审批流程</summary>
public const string Close = "CloseProcess";
/// <summary>
/// 按交易状态推断流程类别:处于平仓/行权/互换待复核的交易视为「了结」类操作。
/// </summary>
public static string Resolve(trade td)
{
if (td == null) return Open;
if (td.TradeStatus == ConsTrade.
|| td.TradeStatus == ConsTrade.
|| td.TradeStatus == ConsTrade.)
{
return Close;
}
return Open;
}
}
}
@@ -1,4 +1,6 @@
using Org.BouncyCastle.Ocsp;
using Newtonsoft.Json;
using YLErp.Helpers;
using YLErp.DBModels.Enums;
using YLErp.Model.Enum;
using YLErp.Modules.AppModule;
@@ -43,7 +45,7 @@ namespace YLErp.Web.Controllers
[HttpPost]
public ActionResult AddProcess(string type, List<ApprovalProcessAddRequest> data)
{
if (type == "TradeProcess" && data != null && data.Count > 0)
if ((type == "TradeProcess" || type == "CloseProcess") && data != null && data.Count > 0)
{
foreach (var item in data)
{
@@ -54,6 +56,25 @@ namespace YLErp.Web.Controllers
}
}
// 校验节点触发条件 JSON 格式,避免非法数据入库
if (data != null)
{
foreach (var item in data)
{
if (!string.IsNullOrWhiteSpace(item.triggerCondition))
{
try
{
JsonConvert.DeserializeObject<ConditionExpressionConfig>(item.triggerCondition);
}
catch
{
return JsonError("触发条件格式非法,请检查括号与条件是否完整");
}
}
}
}
new ApprovalProcessService(CurUser).AddProcess(type, data);
return JsonSuccess("设置成功");
}
@@ -66,10 +87,13 @@ namespace YLErp.Web.Controllers
var tradeProcess = list.Where(s => s.processType == "TradeProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
// 需求②:了结/平仓/行权审批流程
var closeProcess = list.Where(s => s.processType == "CloseProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
var creditProcess = list.Where(s => s.processType == "CreditProcess").OrderBy(s => s.order).ToList();
var outCashProcess = list.Where(s => s.processType == "OutCashProcess").OrderBy(s => s.order).ToList();
var clientProcess = list.Where(s => s.processType == "ClientProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList();
return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess });
return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess });
}
@@ -52,7 +52,7 @@
</div>
</div>
</div>
<template v-model="openItems">
<template>
<template v-for="(item,index) in openItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
@@ -182,7 +182,7 @@
</div>
</div>
</div>
<template v-model="clientItems">
<template>
<template v-for="(item,index) in clientItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
@@ -312,12 +312,12 @@
</div>
</div>
</div>
<template v-model="tradeItems">
<template>
<template v-for="(item,index) in tradeItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:282px;">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(item)"></i>
@@ -334,6 +334,33 @@
<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 class="trigger-condition-box">
<div class="trigger-condition-title">
<span>触发条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(item)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in item._trigger.tokens" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(item,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
@@ -382,7 +409,7 @@
<!--第一个节点非分支 开始-->
<div v-for="childSecond in filterChild(child.node)">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:282px;">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="delProcess(childSecond)"></i>
@@ -399,6 +426,33 @@
<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="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>触发条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(childSecond)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in childSecond._trigger.tokens" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(childSecond,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
@@ -435,6 +489,199 @@
</div>
</div>
<!-- 需求②:交易了结流程(结构同交易流程,绑定 closeItems -->
<div v-show="isClose">
<div style="margin: 10px auto">交易了结流程(平仓/行权/互换)</div>
<div>
<div class="node-wrap">
<div class="end-node">
<div class="end-node-text">
申请人
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeShowProcess(0,false,$event)">+</button>
</div>
</div>
</div>
<template>
<template v-for="(item,index) in closeItems">
<!--第一个节点非分支 开始-->
<div v-show="item.node==0&&item.parentNode==0">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="closeDelProcess(item)"></i>
</div>
<div>
<span>审核角色</span>
<select v-model="item.SelectValue" v-on:change="closeSelectChangeType(item.Index-1,item.SelectValue)" style="width:200px;height:20px;">
<option v-for="option in roleOptions" v-bind:value="option.Value">
{{option.Text}}
</option>
</select>
</div>
<div>
<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 class="trigger-condition-box">
<div class="trigger-condition-title">
<span>触发条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(item)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(item,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in item._trigger.tokens" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(item,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(item,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeShowProcess(item.Index,false,$event)">+</button>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 结束-->
<!--第一个节点为分支 分支开始-->
<div v-show="item.node==1&&item.approvalCondition!=0">
<div class="branch-wrap">
<div class="branch-box-wrap">
<div class="branch-box">
<span class="add-branch" title="添加条件">添加条件</span>
<div class="col-box" v-for="child in closeFilterBranch()">
<div class="condition-node">
<div class="condition-node-box">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width: 282px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">分支{{child.node}}条件</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="closeDelProcess(item)"></i>
</div>
<div>
<span>申请人</span>
<select v-model="child.approvalCondition" style="width:75px;height:20px;">
<option value="1">属于</option>
<option value="2">不属于</option>
</select>
<select v-model="child.approvalGroupId" style="width:143px;height:20px;">
<option v-for="option in grouplist" v-bind:value="option.id">
{{option.groupName}}
</option>
</select>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeAddProcess(item.Index,true,child.node,$event)">+</button>
</div>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 开始-->
<div v-for="childSecond in closeFilterChild(child.node)">
<div class="node-wrap">
<div class="node-wrap-box start-node " style="width:340px;">
<div class="title" style="background: rgb(255, 148, 62);">
<span class="userEdit">审核节点</span>
<i class="glyphicon glyphicon-remove btnRemove" v-on:click="closeDelProcess(childSecond)"></i>
</div>
<div>
<span>审核角色</span>
<select v-model="childSecond.SelectValue" style="width:200px;height:20px;">
<option v-for="option in roleOptions" v-bind:value="option.Value">
{{option.Text}}
</option>
</select>
</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="" />
</div>
<div class="trigger-condition-box">
<div class="trigger-condition-title">
<span>触发条件</span>
<div class="trigger-actions">
<button class="trigger-action-btn" v-on:click="addTriggerCondition(childSecond)">+ 条件</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'left')">+ (</button>
<button class="trigger-action-btn" v-on:click="addTriggerParen(childSecond,'right')">+ )</button>
</div>
</div>
<div class="trigger-token-list">
<div v-for="(tok,ti) in childSecond._trigger.tokens" class="trigger-token-row">
<span v-if="tok.type=='operator'" class="trigger-connector" v-on:click="toggleTriggerConnector(childSecond,ti)">{{tok.connector=='or'?'或':'且'}}</span>
<span v-if="tok.type=='lparen'" class="trigger-paren">(&nbsp;&nbsp;<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='rparen'" class="trigger-paren">)<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i></span>
<span v-if="tok.type=='condition'" class="trigger-condition-row">
<select v-model="tok.condition.field">
<option v-for="opt in availableConditionFields" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<select v-model="tok.condition.op">
<option v-for="opt in conditionOps" v-bind:value="opt.value">{{opt.text}}</option>
</select>
<input v-model="tok.condition.value" type="text" placeholder="阈值" />
<i class="glyphicon glyphicon-remove trigger-remove" v-on:click="removeTriggerToken(childSecond,ti)"></i>
</span>
</div>
</div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeAddProcess(childSecond.Index,true,childSecond.node,$event)">+</button>
</div>
</div>
</div>
</div>
<!--第一个节点非分支 结束-->
<div class="top-left-cover-line" v-if="child.node==1"></div>
<div class="bottom-left-cover-line" v-if="child.node==1"></div>
<div class="top-right-cover-line" v-if="child.node!=1"></div>
<div class="bottom-right-cover-line" v-if="child.node!=1"></div>
</div>
</div>
<div class="node-add-btn-box">
<div class="add-node-btn">
<button class="addNodeClick" v-on:click="closeShowProcess(item.Index,false,$event)">+</button>
</div>
</div>
</div>
</div>
</div>
<!--第一个节点为分支 分支结束-->
</template>
</template>
<!-- 流程结束 -->
<div class="end-node">
<div class="end-node-circle"></div>
<div class="end-node-text">
结束流程
</div>
</div>
</div>
</div>
<div v-show="isCredit">
<div style="margin: 10px auto">资信与授信流程</div>
<div>
@@ -10,11 +10,7 @@ function initSelectize(selector, options) {
});
}
};
if (options) {
_.assign(options, defaultOptions);
} else {
options = defaultOptions;
}
options = _.assign({}, defaultOptions, options || {});
$(selector).selectize(options);
}
@@ -47,18 +43,21 @@ var app = new Vue({
{ text: '客户开户', value: '1' },
{ text: '客户信息修改', value: '5' },
{ text: '交易', value: '2' },
{ text: '交易了结', value: '6' },
/* { text: '资信与授信', value: '3' },*/
{ text: '出金', value: '4' }
],
isOpen: false,
isTrade: false,
isClose: false,
isCredit: false,
isOutCash: false,
isClient: false,
openItems: [],
clientItems: [],
tradeItems: [],
closeItems: [],
creditItems: [],
outCashItems: [],
openCounter: 0,
@@ -67,23 +66,32 @@ var app = new Vue({
clientCounter: 0,
tradeIndex: 0,
grouplist: [],
node: 0
node: 0,
// 需求①:节点触发条件字段(期初名义本金 / 本次交易名义本金)
// 开仓流程只能选期初名义本金;了结流程两个都可选
conditionFields: [
{ value: 'initialNotional', text: '交易的期初名义本金' },
{ value: 'currentNotional', text: '本次交易名义本金' }
],
// 开仓流程可选字段(仅期初)
conditionFieldsOpen: [
{ value: 'initialNotional', text: '交易的期初名义本金' }
],
conditionOps: [
{ value: 'gt', text: '>' },
{ value: 'lt', text: '<' },
{ value: 'gte', text: '>=' },
{ value: 'lte', text: '<=' },
{ value: 'eq', text: '=' },
{ value: 'neq', text: '≠' }
],
conditionLogics: [
{ value: 'AND', text: '满足全部条件时触发' },
{ value: 'OR', text: '满足任一条件时触发' }
],
roleOptions: []
},
computed: {
roleOptions: function () {
var items = [];
main.post("/AccountOpeningProcess/GetRolesOption", {}, { async: false }).done(
function (res) {
for (var i = 0; i < res.length; i++) {
items.push({
Value: res[i].Value,
Text: res[i].Text
});
}
}
);
return items;
},
filterBranch() {
return this.tradeItems.filter(x => x.approvalCondition != 0);
},
@@ -92,9 +100,28 @@ var app = new Vue({
},
clientFilterBranch() {
return this.clientItems.filter(x => x.approvalCondition != 0);
},
// 需求①:开仓流程只能选期初名义本金;了结流程两个都可选
availableConditionFields() {
return this.isClose ? this.conditionFields : this.conditionFieldsOpen;
}
},
methods: {
loadRoleOptions: function () {
var thisObj = this;
main.post("/AccountOpeningProcess/GetRolesOption", {}, { async: false }).done(
function (res) {
var items = [];
for (var i = 0; i < res.length; i++) {
items.push({
Value: res[i].Value,
Text: res[i].Text
});
}
thisObj.roleOptions = items;
}
);
},
changeType: function () {
var thisObj = this;
$("#addtooltip-warpper").hide();
@@ -107,6 +134,14 @@ var app = new Vue({
} else if (thisObj.selected === '2') {
thisObj.isOpen = false;
thisObj.isTrade = true;
thisObj.isClose = false;
thisObj.isCredit = false;
thisObj.isOutCash = false;
thisObj.isClient = false;
} else if (thisObj.selected === '6') { // 需求②:交易了结流程
thisObj.isOpen = false;
thisObj.isTrade = false;
thisObj.isClose = true;
thisObj.isCredit = false;
thisObj.isOutCash = false;
thisObj.isClient = false;
@@ -227,6 +262,9 @@ var app = new Vue({
if (selectType == "5") {
typeName = 'ClientProcess'
}
if (selectType == "6") {
typeName = 'CloseProcess'
}
var item = {
id: 0,
Type: typeName,
@@ -258,7 +296,10 @@ var app = new Vue({
thisObj.clientItems.splice(thisObj.node, 0, item, item2);
thisObj.initClientItemIndex();
}
else if (selectType == "6") { //交易了结
thisObj.closeItems.splice(thisObj.node, 0, item, item2);
thisObj.initCloseItemIndex();
}
}
$("#addtooltip-warpper").hide(100);
@@ -321,6 +362,10 @@ var app = new Vue({
thisObj.addClientProcess(index, child, node);
return;
}
else if (selectType === "6") { //交易了结
thisObj.addCloseNode(index, child, node);
return;
}
},
delProcess: function (openItem) {
@@ -471,7 +516,10 @@ var app = new Vue({
approvalCondition: 0,
node: node,
parentNode: child ? index : 0,
approvalGroupId: 0
approvalGroupId: 0,
conditionConfig: '',
triggerCondition: '',
_trigger: { tokens: [] }
}
thisObj.getRuleType();
var childNodes = thisObj.tradeItems.filter(x => x.Index > index);
@@ -537,6 +585,10 @@ var app = new Vue({
thisObj.clientOk();
return;
}
else if (selectType === "6") { // 需求②:交易了结
thisObj.saveCloseProcess();
return;
}
},
openOk() {
var thisObj = this;
@@ -665,6 +717,12 @@ var app = new Vue({
thisObj.getRuleType();
thisObj.initTradeItemIndex();
var triggerValid = thisObj.validateAllTriggers(thisObj.tradeItems);
if (!triggerValid.valid) {
main.message(triggerValid.message);
return;
}
thisObj.stringifyAllTrigger(thisObj.tradeItems); // 需求①:序列化触发条件
var firstChildTradeItem = thisObj.tradeItems.filter(x => x.node == 1);
var secondchildTradeItem = thisObj.tradeItems.filter(x => x.node == 2);
if (firstChildTradeItem.length == 1 && secondchildTradeItem.length == 1) {
@@ -864,6 +922,7 @@ var app = new Vue({
var thisObj = this;
thisObj.openItems = [];
thisObj.tradeItems = [];
thisObj.closeItems = [];
thisObj.creditItems = [];
thisObj.outCashItems = [];
thisObj.clientItems = [];
@@ -880,7 +939,9 @@ var app = new Vue({
node: value.node,
parentNode: value.parentNode,
approvalGroupId: value.approvalGroupId,
approvalCondition: value.approvalCondition
approvalCondition: value.approvalCondition,
conditionConfig: value.conditionConfig,
triggerCondition: value.triggerCondition
});
});
@@ -894,9 +955,29 @@ var app = new Vue({
node: value.node,
parentNode: value.parentNode,
approvalGroupId: value.approvalGroupId,
approvalCondition: value.approvalCondition
approvalCondition: value.approvalCondition,
conditionConfig: value.conditionConfig,
triggerCondition: value.triggerCondition
});
});
// 需求②:了结/平仓/行权审批流程
if (res.CloseProcess) {
res.CloseProcess.forEach(function (value, index, array) {
thisObj.closeItems.push({
id: value.id,
Type: value.processType,
Index: value.order,
SelectValue: value.roleId,
ApprovalRules: value.ruleType,
node: value.node,
parentNode: value.parentNode,
approvalGroupId: value.approvalGroupId,
approvalCondition: value.approvalCondition,
conditionConfig: value.conditionConfig,
triggerCondition: value.triggerCondition
});
});
}
res.CreditProcess.forEach(function (value, index, array) {
thisObj.creditItems.push({
Type: value.processType,
@@ -920,7 +1001,15 @@ var app = new Vue({
node: value.node,
parentNode: value.parentNode,
approvalGroupId: value.approvalGroupId,
approvalCondition: value.approvalCondition
approvalCondition: value.approvalCondition,
conditionConfig: value.conditionConfig,
triggerCondition: value.triggerCondition
});
});
// 需求①:加载后把 triggerCondition(JSON)解析为结构化对象供 UI 编辑
['openItems', 'tradeItems', 'closeItems', 'clientItems'].forEach(function (arr) {
thisObj[arr].forEach(function (item) {
thisObj.parseTriggerCondition(item);
});
});
}
@@ -982,6 +1071,376 @@ var app = new Vue({
thisObj.tradeItems = newSortTradeItems.sort((a, b) => a.Index - b.Index);
thisObj.initRuleType();
},
// ===== 需求①:节点触发条件 结构化编辑器(token 数组,支持每行独立且/或 + 括号)=====
// _trigger = { tokens: [ {type:'condition',condition:{field,op,value}}, {type:'operator',connector:'and'|'or'}, {type:'lparen'|'rparen'} ] }
// op 用字母标识符(gt/lt/gte/lte/eq/neq),规避 > < 在传输中被 HTML 编码导致加载不出来的问题
// 把历史符号 op 归一化为字母标识符
normalizeOp(op) {
var map = { '>': 'gt', '<': 'lt', '>=': 'gte', '<=': 'lte', '==': 'eq', '!=': 'neq', '=': 'eq', '<>': 'neq' };
if (!op) return 'gt';
var lower = ('' + op).toLowerCase();
if (['gt','lt','gte','lte','eq','neq'].indexOf(lower) >= 0) return lower;
return map[op] || lower;
},
// 字段归一化:历史 notional → initialNotional;空默认 initialNotional
normalizeField(field) {
if (!field) return 'initialNotional';
var f = ('' + field);
// 历史兼容
if (f.toLowerCase() == 'notional') return 'initialNotional';
return f;
},
// 把 item.triggerCondition(JSON 字符串)解析为 item._trigger 结构化对象供 UI 编辑
parseTriggerCondition(item) {
if (!item) return;
if (item._trigger) return; // 已解析则不重复处理
var thisObj = this;
var parsed = { tokens: [] };
if (item.triggerCondition) {
try {
var obj = JSON.parse(item.triggerCondition);
if (obj.tokens && obj.tokens.length) {
// 新结构:直接复用 token,规范化字段
parsed.tokens = obj.tokens.map(function (t) {
if (t.type == 'condition') {
var c = t.condition || {};
return { type: 'condition', condition: { field: thisObj.normalizeField(c.field), op: thisObj.normalizeOp(c.op), value: c.value != null ? c.value : '' } };
}
if (t.type == 'operator') return { type: 'operator', connector: (t.connector || 'and').toLowerCase() };
if (t.type == 'lparen') return { type: 'lparen' };
if (t.type == 'rparen') return { type: 'rparen' };
return null;
}).filter(function (t) { return t != null; });
}
} catch (e) {
// 非法 JSON,按空处理
}
}
this.$set(item, '_trigger', parsed);
},
// 新增一条触发条件(自动在前方补连接符,首个不补)
addTriggerCondition(item) {
this.parseTriggerCondition(item);
var toks = item._trigger.tokens;
if (toks.length > 0) {
var last = toks[toks.length - 1];
// 若上一 token 是条件或右括号,则需要连接符
if (last.type == 'condition' || last.type == 'rparen') {
toks.push({ type: 'operator', connector: 'and' });
}
}
toks.push({ type: 'condition', condition: { field: 'initialNotional', op: 'gt', value: '' } });
},
// 切换某连接符 and/or
toggleTriggerConnector(item, idx) {
var t = item._trigger.tokens[idx];
if (t && t.type == 'operator') {
t.connector = (t.connector == 'and') ? 'or' : 'and';
}
},
// 插入左/右括号
addTriggerParen(item, side) {
this.parseTriggerCondition(item);
var toks = item._trigger.tokens;
var type = side == 'left' ? 'lparen' : 'rparen';
if (type == 'lparen') {
// 左括号前若接条件/右括号,需补连接符
if (toks.length > 0) {
var last = toks[toks.length - 1];
if (last.type == 'condition' || last.type == 'rparen') {
toks.push({ type: 'operator', connector: 'and' });
}
}
toks.push({ type: 'lparen' });
} else {
toks.push({ type: 'rparen' });
}
},
// 删除一个 token,并清理其旁孤立连接符
removeTriggerToken(item, idx) {
var toks = item._trigger.tokens;
if (idx < 0 || idx >= toks.length) return;
toks.splice(idx, 1);
// 清理首部连接符 / 尾部连接符 / 连续连接符
var i = 0;
while (i < toks.length) {
var t = toks[i];
if (t.type == 'operator' && (i == 0 || i == toks.length - 1
|| toks[i - 1].type == 'operator')) {
toks.splice(i, 1);
} else {
i++;
}
}
},
// 保存前:把 item._trigger 序列化回 item.triggerCondition JSON
stringifyTriggerCondition(item) {
if (!item || !item._trigger) return;
var toks = item._trigger.tokens || [];
if (toks.length == 0) {
item.triggerCondition = '';
return;
}
// 过滤掉无效条件(字段/值缺失)
var valid = toks.filter(function (t) {
if (t.type == 'condition') {
var c = t.condition || {};
return c.field && c.op && c.value !== '';
}
return true;
});
// 移除首尾连接符
while (valid.length > 0 && valid[0].type == 'operator') valid.shift();
while (valid.length > 0 && valid[valid.length - 1].type == 'operator') valid.pop();
item.triggerCondition = valid.length > 0 ? JSON.stringify({ tokens: valid }) : '';
},
// 批量把一组 items 的 _trigger 序列化(保存前调用)
stringifyAllTrigger(items) {
var thisObj = this;
if (!items) return;
items.forEach(function (item) {
if (item.node == 0 || item.approvalCondition == 0) {
thisObj.stringifyTriggerCondition(item);
}
});
},
// 校验触发条件 token 列表是否合法:括号平衡、无空括号、无首尾连接符等
validateTriggerTokens(tokens) {
if (!tokens || tokens.length == 0) return { valid: true, message: '' };
var paren = 0;
for (var i = 0; i < tokens.length; i++) {
var t = tokens[i];
if (t.type == 'lparen') paren++;
if (t.type == 'rparen') paren--;
if (paren < 0) return { valid: false, message: '触发条件中右括号过多,请检查括号匹配' };
if (t.type == 'operator') {
if (i == 0 || i == tokens.length - 1) return { valid: false, message: '触发条件首尾不能为连接符(且/或)' };
var prev = tokens[i - 1];
if (prev.type == 'operator' || prev.type == 'lparen') return { valid: false, message: '触发条件中连接符(且/或)位置不正确' };
}
if (t.type == 'condition') {
var c = t.condition || {};
if (!c.field || !c.op || c.value === '') return { valid: false, message: '触发条件中存在未填写完整的条件' };
}
}
if (paren != 0) return { valid: false, message: '触发条件中括号不匹配,请检查' };
return { valid: true, message: '' };
},
// 校验一组 items 的触发条件,返回第一个错误信息
validateAllTriggers(items) {
var thisObj = this;
if (!items) return { valid: true, message: '' };
for (var i = 0; i < items.length; i++) {
var item = items[i];
if ((item.node == 0 || item.approvalCondition == 0) && item._trigger && item._trigger.tokens) {
var res = thisObj.validateTriggerTokens(item._trigger.tokens);
if (!res.valid) return res;
}
}
return { valid: true, message: '' };
},
// ===== 需求②:交易了结流程(closeItems)方法集,算法同交易流程 =====
closeFilterBranch() {
return this.closeItems.filter(x => x.approvalCondition != 0);
},
closeFilterChild(node) {
return this.closeItems.filter(x => x.node == node && x.approvalCondition == 0);
},
initCloseItemIndex() {
var thisObj = this;
var newSortItems = [];
var mainItems = thisObj.closeItems.filter(x => x.node == 0).sort((a, b) => a.Index - b.Index);
var childItem = thisObj.closeItems.filter(x => x.node != 0);
var childItemSort = childItem.sort((a, b) => a.Index - b.Index);
var firstChildIndex = childItemSort.length > 0 ? childItemSort[0].Index : -1;
var j = 1;
for (var i = 0; i < mainItems.length; i++) {
if (mainItems[i].Index >= firstChildIndex) {
if (childItemSort.length > 0) {
mainItems[i].Index = childItemSort[childItemSort.length - 1].Index + j;
j++;
} else {
mainItems[i].Index = i + 1;
}
}
newSortItems.push(mainItems[i]);
}
var firstChildItems = childItem.filter(x => x.node == 1);
var secondChildItems = childItem.filter(x => x.node == 2);
firstChildItems.forEach((x, index) => {
if (x.approvalCondition == 0) {
x.Index = firstChildIndex + index;
x.parentNode = firstChildIndex + index - 1;
}
newSortItems.push(x);
});
secondChildItems.forEach((x, index) => {
if (x.approvalCondition == 0) {
x.Index = firstChildIndex + index;
x.parentNode = firstChildIndex + index - 1;
}
newSortItems.push(x);
});
thisObj.closeItems = newSortItems.sort((a, b) => a.Index - b.Index);
thisObj.initRuleType();
},
closeShowProcess(index, child, obj) {
var thisObj = this;
var closeBranch = thisObj.closeItems.find(x => x.approvalCondition != 0);
if (!closeBranch) {
let offsetTop = $(obj.target).offset().top;
let offsetLeft = $(obj.target).offset().left;
$("#addtooltip-warpper").css({ "left": offsetLeft - 180, "top": offsetTop - 110, "margin-left": "30px" });
$("#addtooltip-warpper").hide();
$("#addtooltip-warpper").show(150);
this.tradeIndex = index;
thisObj.node = index;
} else {
thisObj.closeAddProcess(index, child, 0);
$("#addtooltip-warpper").hide(100);
}
},
closeAddProcess(index, child, node) {
var thisObj = this;
thisObj.addCloseNode(index, child, node);
$("#addtooltip-warpper").hide(100);
},
addCloseNode(index, child, node) {
var thisObj = this;
var item = {
id: 0,
Type: 'CloseProcess',
Index: index + 1,
SelectValue: 0,
ApprovalRules: '',
node: node,
parentNode: child ? index : 0,
approvalGroupId: 0,
approvalCondition: 0,
conditionConfig: '',
triggerCondition: '',
_trigger: { tokens: [] }
};
thisObj.closeItems.forEach(x => {
if (x.Index >= index) {
x.Index = x.Index + 1;
}
});
thisObj.closeItems.splice(index, 0, item);
thisObj.initCloseItemIndex();
},
closeDelProcess(closeItem) {
var thisObj = this;
thisObj.getCloseRuleType();
if (closeItem.approvalCondition != 0) {//删除分支下,所有东西
var childNodes = thisObj.closeItems.filter(x => x.node != 0);
thisObj.closeItems = thisObj.closeItems.concat(childNodes).filter(function (v) {
return thisObj.closeItems.indexOf(v) === -1 || childNodes.indexOf(v) === -1
});
thisObj.initCloseItemIndex();
} else if (closeItem.node != 0) {//删除分支下某节点
var index = thisObj.closeItems.indexOf(closeItem);
thisObj.closeItems.splice(index, 1);
var childNodes = thisObj.closeItems.filter(x => x.node == closeItem.node && x.Index >= closeItem.Index);
childNodes.forEach(item => {
item.parentNode = item.parentNode - 1;
item.Index = item.Index - 1;
});
} else {//删除非分支下某节点
var index = thisObj.closeItems.indexOf(closeItem);
thisObj.closeItems.splice(index, 1);
var nodes = thisObj.closeItems.filter(x => x.Index >= closeItem.Index);
nodes.forEach(item => {
item.Index = item.Index - 1;
});
thisObj.initCloseItemIndex();
}
},
closeSelectChangeType(index, value) {
var thisObj = this;
for (var j = 0; j < thisObj.closeItems.length; j++) {
if (j === index) continue;
if (thisObj.closeItems[j].SelectValue === parseInt(value) && thisObj.closeItems[j].node == 0) {
main.message("不得选择重复角色");
return;
}
}
},
saveCloseProcess() {
var thisObj = this;
// 角色非空校验
for (var i = 0; i < thisObj.closeItems.length; i++) {
if (thisObj.closeItems[i].SelectValue === "" || thisObj.closeItems[i].SelectValue === 0) {
if (thisObj.closeItems[i].approvalCondition == 0) {
main.message('流程中断,请重新选择');
return;
}
}
}
var temp1 = 0;
var closeLength = thisObj.closeItems.length;
for (var w1 = 0; w1 < closeLength - 1; w1++) {
for (var n1 = 1; n1 < closeLength; n1++) {
if (w1 !== n1) {
if (parseInt(thisObj.closeItems[w1].SelectValue) === parseInt(thisObj.closeItems[n1].SelectValue) && parseInt(thisObj.closeItems[n1].SelectValue) != 0) {
if (parseInt(thisObj.closeItems[w1].node) == parseInt(thisObj.closeItems[n1].node)) {
temp1 = temp1 + 1;
}
}
}
}
}
var approvalGroupItems = thisObj.closeItems.filter(x => x.approvalGroupId != 0);
var approvalCloseLength = approvalGroupItems.length;
var temp2 = 0;
for (var w1 = 0; w1 < approvalCloseLength - 1; w1++) {
for (var n1 = 1; n1 < approvalCloseLength; n1++) {
if (w1 !== n1) {
if (parseInt(approvalGroupItems[w1].approvalCondition) === parseInt(approvalGroupItems[n1].approvalCondition) && parseInt(approvalGroupItems[w1].approvalGroupId) === parseInt(approvalGroupItems[n1].approvalGroupId)) {
temp2 = temp2 + 1;
}
}
}
}
if (temp1 > 0 || temp2 > 0) {
main.message('流程包含重复项,请重新选择');
return;
}
thisObj.getCloseRuleType();
thisObj.initCloseItemIndex();
var firstChildCloseItem = thisObj.closeItems.filter(x => x.node == 1);
var secondchildCloseItem = thisObj.closeItems.filter(x => x.node == 2);
if (firstChildCloseItem.length == 1 && secondchildCloseItem.length == 1) {
main.message('分支节点流程未设置完毕');
return;
}
var triggerValid = thisObj.validateAllTriggers(thisObj.closeItems);
if (!triggerValid.valid) {
main.message(triggerValid.message);
return;
}
thisObj.stringifyAllTrigger(thisObj.closeItems); // 需求①:序列化触发条件
if (thisObj.closeItems != null && thisObj.closeItems.length > 0) {
main.confirm("确认修改交易了结审批流程?", function () {
main.post("/AccountOpeningProcess/AddProcess",
{ type: "CloseProcess", data: thisObj.closeItems },
{ async: false }).done(
function (res) {
thisObj.getProcess();
});
});
} else {
main.confirm("删除审批流程后,交易了结审批就会直接审批通过,确认删除?", function () {
main.post("/AccountOpeningProcess/AddProcess",
{ type: "CloseProcess" },
{ async: false }).done(
function (res) {
});
});
}
},
initOpenItemIndex() {
var thisObj = this;
var newSortOpenItems = [];
@@ -1058,41 +1517,58 @@ var app = new Vue({
});
thisObj.clientItems = newSortClientItems.sort((a, b) => a.Index - b.Index);
},
initRuleType() {
initRuleTypeFor(items, prefix) {
var thisObj = this;
var tradeLength = thisObj.tradeItems.length;
for (var i = 0; i < tradeLength; i++) {
var ruleId = "#ruleType" + thisObj.tradeItems[i].Index + thisObj.tradeItems[i].node;
var length = items.length;
for (var i = 0; i < length; i++) {
var ruleId = "#" + prefix + items[i].Index + items[i].node;
var $selectize = $(ruleId).get(0);
if (thisObj.tradeItems[i].ApprovalRules != null && thisObj.tradeItems[i].ApprovalRules.length > 0) {
var ruleArr = thisObj.tradeItems[i].ApprovalRules.split(',');
if (items[i].ApprovalRules != null && items[i].ApprovalRules.length > 0) {
var ruleArr = items[i].ApprovalRules.split(',');
$selectize.selectize.setValue(ruleArr);
} else if ($selectize) {
$selectize.selectize.setValue([]);
}
}
},
getRuleType() {
initRuleType() {
this.initRuleTypeFor(this.tradeItems, 'ruleType');
this.initRuleTypeFor(this.closeItems, 'closeRuleType');
},
getRuleTypeFor(items, prefix) {
var thisObj = this;
var tradeLength = thisObj.tradeItems.length;
for (var i = 0; i < tradeLength; i++) {
var ruleId = "#ruleType" + thisObj.tradeItems[i].Index + thisObj.tradeItems[i].node;
var length = items.length;
for (var i = 0; i < length; i++) {
var ruleId = "#" + prefix + items[i].Index + items[i].node;
var $selectize = $(ruleId).get(0);
if ($selectize) {
var ruleArr = $selectize.selectize.items;
thisObj.tradeItems[i].ApprovalRules = ruleArr.join(',');
items[i].ApprovalRules = ruleArr.join(',');
}
}
},
getRuleType() {
this.getRuleTypeFor(this.tradeItems, 'ruleType');
},
getCloseRuleType() {
this.getRuleTypeFor(this.closeItems, 'closeRuleType');
}
},
mounted: function () {
this.getProcess();
this.getApprovalGroup();
this.loadRoleOptions();
},
watch: {
tradeItems:
{
handler(val, oldVal) {
this.$nextTick(function () {
setRuleDiv();
})
},
},
closeItems:
{
handler(val, oldVal) {
this.$nextTick(function () {
@@ -430,6 +430,141 @@ html {
.glyphicon {
color: #FFFFFF !important;
}
button:focus {
outline: none;
.trigger-condition-box {
background-color: #FFFFFF;
border-radius: 5px;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1);
border: 1px solid #F5F5F5;
margin: 8px 10px 10px 10px;
overflow: hidden;
}
.trigger-condition-title {
background: rgb(255, 148, 62);
color: #FFFFFF;
font-size: 12px;
padding: 5px 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.trigger-condition-title .trigger-actions {
display: flex;
gap: 6px;
}
.trigger-action-btn {
display: inline-block;
border-radius: 15px;
color: rgb(50, 150, 250);
border: none;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1);
background: #FFFFFF;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
line-height: 18px;
}
.trigger-action-btn:hover {
background: #f5f7fa;
transform: scale(1.05);
transition: all .3s;
}
.trigger-token-list {
padding: 8px 10px;
}
.trigger-token-row {
display: flex;
align-items: center;
justify-content: center;
min-height: 26px;
margin-bottom: 4px;
width: 100%;
}
.trigger-token-row:last-child {
margin-bottom: 0;
}
.trigger-connector {
display: inline-block;
width: 60px;
text-align: center;
color: #409eff;
cursor: pointer;
font-size: 12px;
}
.trigger-paren {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 12px;
padding: 4px 0;
position: relative;
}
.trigger-paren .trigger-remove,
.trigger-condition-row .trigger-remove {
opacity: 0.85;
}
.trigger-paren:hover .trigger-remove,
.trigger-condition-row:hover .trigger-remove {
opacity: 1;
}
.trigger-paren .trigger-remove {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.trigger-condition-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.trigger-condition-row .trigger-remove {
margin-left: auto;
}
.trigger-condition-row select,
.trigger-condition-row input {
height: 20px;
font-size: 12px;
padding: 0 2px;
box-sizing: border-box;
}
.trigger-condition-row select {
width: auto;
min-width: 70px;
}
.trigger-condition-row input {
width: 80px;
padding-left: 4px;
}
.trigger-remove {
color: #ff4d4f !important;
cursor: pointer;
margin-left: 4px;
font-size: 11px;
opacity: 0.7;
transition: opacity 0.2s;
}
.trigger-remove:hover {
color: #ff7875 !important;
opacity: 1;
}