From fb59c6180a9ba03feb81d3c86bbfa249efb396b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Tue, 7 Jul 2026 15:47:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=B8=AD=E7=9A=84=E5=88=86=E6=94=AF=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E5=92=8C=E8=8A=82=E7=82=B9=E8=A7=A6=E5=8F=91=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/Approvalprocess.cs | 16 + .../Helpers/ConditionEvaluatorTests.cs | 366 ++++++++++++ .../Helpers/TriggerNodeSkipTests.cs | 162 +++++ YLErpDAL/Helpers/ConditionEvaluator.cs | 348 +++++++++++ .../SystemModule/ApprovalProcessService.cs | 114 +++- .../DealModule/TradeOpenService.cs | 11 +- .../Modules/TradeModule/TradeServiceBase.cs | 163 +++++- .../AccountOpeningProcessController.cs | 28 +- .../Views/AccountOpeningProcess/Index.cshtml | 257 +++++++- .../Scripts/app/system/Approvalprocess.js | 554 ++++++++++++++++-- .../selectize/css/accountOpeningProcess.css | 141 ++++- 11 files changed, 2104 insertions(+), 56 deletions(-) create mode 100644 UnitTestProject/Helpers/ConditionEvaluatorTests.cs create mode 100644 UnitTestProject/Helpers/TriggerNodeSkipTests.cs create mode 100644 YLErpDAL/Helpers/ConditionEvaluator.cs diff --git a/Framework/YLErp.Core/DBModels/Approvalprocess.cs b/Framework/YLErp.Core/DBModels/Approvalprocess.cs index aea2c478..ee1339a2 100644 --- a/Framework/YLErp.Core/DBModels/Approvalprocess.cs +++ b/Framework/YLErp.Core/DBModels/Approvalprocess.cs @@ -54,5 +54,21 @@ namespace YLErp.DBModels /// [DisplayName("审批组条件")] public int? approvalCondition { get; set; } + + /// + /// 分支网关条件(JSON)。 + /// 用于多分支流程的进入条件判断,结构见 ConditionExpressionConfig。 + /// 为空时回退到旧的 approvalGroupId/approvalCondition 二元判断(双写兼容)。 + /// + [DisplayName("分支网关条件")] + public string conditionConfig { get; set; } + + /// + /// 节点触发条件(JSON)。 + /// 仅挂在审批节点(node=0)上:推进到该节点时求值,满足才进入该节点审批;不满足则跳过该节点。 + /// 为空视为无条件(默认进入审批)。例:名义本金 A默认审核、B>100W再审核、C>=500W再审核。 + /// + [DisplayName("节点触发条件")] + public string triggerCondition { get; set; } } } diff --git a/UnitTestProject/Helpers/ConditionEvaluatorTests.cs b/UnitTestProject/Helpers/ConditionEvaluatorTests.cs new file mode 100644 index 00000000..086cca6a --- /dev/null +++ b/UnitTestProject/Helpers/ConditionEvaluatorTests.cs @@ -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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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)); + } + + /// 辅助:单条件 tokens 序列化为 JSON。 + private static string BuildTokens(ConditionItem item) + { + return JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new System.Collections.Generic.List + { + new ConditionToken { type = "condition", condition = item } + } + }); + } + } +} diff --git a/UnitTestProject/Helpers/TriggerNodeSkipTests.cs b/UnitTestProject/Helpers/TriggerNodeSkipTests.cs new file mode 100644 index 00000000..30536f89 --- /dev/null +++ b/UnitTestProject/Helpers/TriggerNodeSkipTests.cs @@ -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 +{ + /// + /// 需求①:交易提交进入审批流程时的「起点触发条件跳过」测试。 + /// 场景:交易一进来,若节点1、2配置的触发条件均不满足,应直接从节点3开始审核; + /// 若全部节点都不满足,则直接审批通过(无需任何审核)。 + /// + [TestClass] + public class TriggerNodeSkipTests + { + /// 构造一个审批节点:order + 可选的触发条件JSON。 + private static approvalprocess Node(int order, string triggerCondition = null) + { + return new approvalprocess + { + order = order, + node = 0, + triggerCondition = triggerCondition + }; + } + + /// 构造"期初名义本金 > 阈值"的触发条件JSON。 + private static string InitialNotionalGt(double threshold) + { + return JsonConvert.SerializeObject(new ConditionExpressionConfig + { + tokens = new List + { + 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 { 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 { Node(1) }; + var result = ConditionEvaluator.FindFirstTriggeredNode(nodes, null, Ctx(500)); + Assert.IsNull(result); + } + } +} diff --git a/YLErpDAL/Helpers/ConditionEvaluator.cs b/YLErpDAL/Helpers/ConditionEvaluator.cs new file mode 100644 index 00000000..4a03fb01 --- /dev/null +++ b/YLErpDAL/Helpers/ConditionEvaluator.cs @@ -0,0 +1,348 @@ +using BaseOUDAL; +using Newtonsoft.Json; + +namespace YLErp.Helpers +{ + /// + /// 审批条件求值器:统一支撑「节点触发条件」与「分支网关条件」。 + /// 需求①(节点触发)与需求③(多分支)共用同一套条件模型,避免两套条件语义。 + /// 支持混合「且/或」与「括号」的布尔表达式,如 A and (B or C)。 + /// + public static class ConditionEvaluator + { + /// + /// 求值条件 JSON。 + /// + /// 条件 JSON(结构见 ConditionExpressionConfig);为空/null 视为无条件,返回 false(不触发)。 + /// 业务上下文(交易/发起人等)。 + /// 是否满足条件 + public static bool Evaluate(string conditionJson, ConditionContext context) + { + if (string.IsNullOrWhiteSpace(conditionJson)) + { + return false; + } + + ConditionExpressionConfig config; + try + { + config = JsonConvert.DeserializeObject(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(); + } + + /// + /// 从起点节点开始,向后查找第一个满足触发条件(或无触发条件)的审批节点(需求①)。 + /// 用于交易提交进入审批流程时确定起始审批节点:若起点节点配置了触发条件且当前业务不满足, + /// 则跳过该节点继续向后找,直到找到可进入的节点;若从起点到末尾均不满足则返回 null(表示无需审批,直接通过)。 + /// + /// 流程全部节点(已按 order 排序) + /// 起点节点 + /// 条件求值上下文 + /// 第一个应进入审批的节点;若无需审批则返回 null + public static approvalprocess FindFirstTriggeredNode( + List 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; + } + + /// 求值单个条件。 + 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); + } + + /// 比较:能转数值时按数值比,否则按字符串比。 + 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 + }; + } + + /// + /// 归一化操作符:兼容字母标识符(gt/lt/gte/lte/eq/neq)与符号(> < >= <= == != =)。 + /// + 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); + } + } + + /// + /// 递归下降解析器:按 token 顺序求值布尔表达式,支持括号与「且/或」优先级。 + /// 文法:Expr := Term (("and"|"or") Term)* ;Term := condition | "(" Expr ")"。 + /// 优先级:and 高于 or(与常规布尔代数一致);同级从左到右。 + /// + internal class ConditionParser + { + private readonly List _tokens; + private readonly ConditionContext _context; + private int _pos; + + public ConditionParser(List tokens, ConditionContext context) + { + _tokens = tokens ?? new List(); + _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); + } + + /// 条件业务上下文:求值时由调用方构造,封装可参与判断的业务字段。 + public class ConditionContext + { + /// 发起人 userId(用于查发起人审批组)。 + public int? UserId { get; set; } + + /// 交易实体(期初名义本金等字段来源)。开户场景可为 null。 + public trade Trade { get; set; } + + /// 交易流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②用。 + public string ProcessCategory { get; set; } + + /// 预解析的发起人审批组(避免重复查库;为空时由 FieldResolver 查)。 + public int? InitGroupId { get; set; } + + /// 本次交易名义本金的取值(了结场景由调用方从 trade_cash.UnwindStockEqvNotional 取绝对值传入)。需求①。 + public double? CurrentNotional { get; set; } + } + + /// + /// 条件表达式配置(对应 conditionConfig / triggerCondition 列的 JSON 结构)。 + /// tokens:token 序列,支持「且/或」混合与括号,按表达式顺序排列。 + /// + public class ConditionExpressionConfig + { + /// token 序列:条件 / 且或连接符 / 左右括号,按表达式顺序排列。 + public List tokens { get; set; } + } + + /// + /// 表达式 token:一个条件、一个连接符、或一个括号。 + /// + public class ConditionToken + { + /// token 类型:condition | operator | lparen | rparen + public string type { get; set; } + + /// 当 type=condition 时的条件体。 + public ConditionItem condition { get; set; } + + /// 当 type=operator 时的连接符:and | or + public string connector { get; set; } + } + + /// 单个条件:左值字段 + 操作符 + 右值。 + public class ConditionItem + { + /// 左值字段 key,见 FieldResolver(initGroup/notional/tradeType 等)。 + public string field { get; set; } + + /// 操作符:> < >= <= == != = in + public string op { get; set; } + + /// 右值 + public object value { get; set; } + } + + /// + /// 条件左值解析:把 field key 映射到具体业务字段值。 + /// 触发条件字段(需求①): + /// - initialNotional:交易的期初名义本金(trade.OriginalStockEqvNotional,已取绝对值) + /// - currentNotional :本次交易名义本金(了结场景,由调用方从 trade_cash 取本次影响金额绝对值传入) + /// 历史兼容:initGroup/tradeType/processCategory/notional 仍可解析。 + /// + 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; + } + } + } +} diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index 83a9b973..12a3c245 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -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; } + + /// + /// 需求①:开仓交易首次进入审批流程时,从起始节点应用触发条件。 + /// 跳过起始就不满足触发条件的节点;若所有节点均不满足 → 直接审批通过。 + /// + private void ApplyTriggerOnStart(List 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_cash,CurrentNotional 不设) + 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; + } + } + /// /// 获取所有交易审批节点 /// @@ -668,6 +757,21 @@ namespace YLErp.Modules.SystemModule return tradeOrders; } + /// + /// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。 + /// 了结类优先取 CloseProcess;未配置则回退 TradeProcess。 + /// + public List 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; + } + } /// @@ -686,6 +790,12 @@ namespace YLErp.Modules.SystemModule public int node { get; set; } public int parentNode { get; set; } public int approvalCondition { get; set; } + + /// 分支网关条件(JSON),需求①③共用。为空则回退旧 approvalGroupId/approvalCondition 二元判断。 + public string conditionConfig { get; set; } + + /// 节点触发条件(JSON),需求①:满足才进入该审批节点,不满足则跳过。 + public string triggerCondition { get; set; } } /// /// 审批流程修改节点 diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 448967ec..d5d53ba1 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -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); diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index aab9e45d..634f5a0d 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -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; } /// - /// 获取所有交易审批节点 + /// 获取所有交易审批节点(开仓流程 TradeProcess)。 /// - /// public List TradeProcess() { var tradeOrders = DbContext.approvalprocess.Where(t => t.processType == "TradeProcess").OrderBy(o => o.order).ToList(); return tradeOrders; } + + /// + /// 按交易推断流程类别(开仓/了结)获取审批节点(需求②)。 + /// 了结类(平仓待复核/行权待复核/互换待复核)优先取 CloseProcess;若未配置则回退 TradeProcess,避免卡单。 + /// + public List 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; + } + + /// + /// 按类别统计审批节点数(需求②)。 + /// + public int TradeProcessCountByCategory(trade td) + { + return TradeProcessByCategory(td).Count; + } + /// /// 初始化交易 审批点 /// @@ -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); + } + + /// + /// 从当前 ProcessOrderId 起点开始,按触发条件跳过无需审批的节点(需求①)。 + /// 场景:交易提交进入审批流程时,若节点1、2配置的触发条件均不满足,则直接跳到节点3; + /// 若全部节点都不满足,则直接审批通过。 + /// + private void ApplyTriggerFromStart(List 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; + } + } + + /// + /// 节点触发条件(需求①):在已确定下一审批节点 nextOrder 后,若该节点配置了 triggerCondition, + /// 则只有「满足触发条件」才进入该节点审批;不满足则跳过该节点,继续向后寻找,直到找到可进入的节点或抵达流程末尾。 + /// 语义:triggerCondition 为空 → 无条件进入审批;非空 → 满足才进入,不满足则跳过。 + /// 非侵入式:原有分支推进逻辑不变,仅在其结果之上叠加触发判断循环。 + /// + /// 当前流程的全部节点(已按 order 排序) + /// 原逻辑计算出的下一节点(可能为 null) + /// 条件求值业务上下文(已含 trade、本次交易名义本金等) + /// 最终应推进到的节点;若应结束流程则返回 null + protected static approvalprocess AdvanceThroughTriggerNodes( + List 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; + } + + /// + /// 构建触发条件求值上下文:从 trade 及其关联的 trade_cash 取本次交易名义本金(了结场景)。 + /// 本次交易名义本金 = 本次了结操作的 trade_cash.UnwindStockEqvNotional 绝对值。 + /// + 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; + } + + /// + /// 按交易状态推断流程类别:开仓(TradeProcess) / 了结(CloseProcess)。需求②。 + /// 委托给 ProcessCategoryConst.Resolve,供全局复用。 + /// + protected static string ResolveProcessCategory(trade td) + { + return ProcessCategoryConst.Resolve(td); } /// /// 添加交易操作日志 @@ -521,4 +651,31 @@ namespace YLErp.Modules.TradeModule return rateCalcModeValue; } } + + /// + /// 交易流程类别常量(需求②):对应 approvalprocess.processType 的取值。 + /// + public static class ProcessCategoryConst + { + /// 开仓审批流程 + public const string Open = "TradeProcess"; + + /// 了结/平仓/行权审批流程 + public const string Close = "CloseProcess"; + + /// + /// 按交易状态推断流程类别:处于平仓/行权/互换待复核的交易视为「了结」类操作。 + /// + 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; + } + } } diff --git a/YLErpWeb/Controllers/AccountOpeningProcessController.cs b/YLErpWeb/Controllers/AccountOpeningProcessController.cs index db9a39aa..9d45614a 100644 --- a/YLErpWeb/Controllers/AccountOpeningProcessController.cs +++ b/YLErpWeb/Controllers/AccountOpeningProcessController.cs @@ -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 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(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 }); } diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index c5be4363..86f3dded 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -52,7 +52,7 @@ -