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

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
@@ -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);
}
}
}