From 9da3a1ed915e36f82a9ee83b580b66bb14267b47 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 17:53:18 +0800 Subject: [PATCH 01/88] =?UTF-8?q?refactor(swap):=20ClearSwapPositions?= =?UTF-8?q?=E6=94=B9virtual(=E4=B8=BA=E9=87=8D=E5=A4=8D=E6=94=B6=E7=9B=98?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=E9=93=BA=E8=B7=AF?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapTradeBaseService.ClearSwapPositions 从 public void 改为 public virtual, 使测试子类可 override 模拟清理逻辑(按ClientCashId关联删除出入金)。 为零行为变更(未被override时按原实现执行)。SwapModule 171测试全绿,无回归。 为后续 DuplicateSwapIncomeReproductionTest(重复收盘不累积出入金)端到端测试铺路。 当前分支已有 ClearSwapPositionsScenarioTest(CSW_001-004)覆盖清理单元逻辑, 端到端测试将补充'重复收盘场景'的集成验证。 --- YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index d1a54850..90a018be 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -411,7 +411,7 @@ namespace YLErp.Modules.SwapModule /// 合成持仓/日终归档 清除互换持仓所有信息 /// /// - public void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) + public virtual void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { var swapEvents = DbContext.swap_event.Where(x => x.SwapTradeId == td.id && x.ValueDate >= valueDate && eventTypes.Contains(x.EventType)); var eventIds = swapEvents.Select(s => s.id).ToList(); From 99958f9b43be3e187e60b3697f2541aa8d24e910 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 18:03:38 +0800 Subject: [PATCH 02/88] =?UTF-8?q?test(swap):=20SwapDealSettlementTest?= =?UTF-8?q?=E8=A1=A5=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93+=E5=90=AB?= =?UTF-8?q?=E9=A2=84=E4=BB=98=E9=87=91=E5=9C=BA=E6=99=AF(=E5=80=9F?= =?UTF-8?q?=E9=89=B4testable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario2,补当前分支缺失的2个场景: SD_007 部分平仓:CloseMethod=部分平仓 → HasPartialUnWind=1,TradeStatus不变, StockEqvNotional/TradeAmount按比例扣减(原100万-50万=50万) SD_008 含预付金平仓:SwapMarginAmount≠0 → 2条资金流水(平仓费+应付预付金) SwapDealSettlementTest 现8场景,SwapModule 173测试全绿(+2),无回归。 --- .../SwapModule/SwapDealSettlementTest.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs b/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs index 85869479..a5c4b50a 100644 --- a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs @@ -223,6 +223,73 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); } + // ================================================================ + // SD_007:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 + // ================================================================ + + /// + /// [SD_007] SwapUnwind 部分平仓(借鉴 testable SwapUnwindScenarioTest.Scenario2) + /// ------------------------------------------------------------ + /// 部分平仓(CloseMethod≠全部平仓 且 ClosePercent≠1)走 else 分支: + /// td.HasPartialUnWind = 1,TradeStatus 保持"确认成交"不变 + /// StockEqvNotional/TradeAmount 仍按 CloseNotionalValue/CloseQty 扣减 + /// + [TestMethod] + public void SD_007_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() + { + var td = CreateTrade(); + var service = new StubDealService(td); + // 部分平仓:ClosePercent=0.5, CloseQty=5000, CloseNotionalValue=500000 + var unwindData = CreateUnwindData( + swapRealizedPnL: 3000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, + closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + // 部分平仓:设 HasPartialUnWind=1,TradeStatus 不变 + Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + // 持仓扣减:原 StockEqvNotional=1000000 - 500000 = 500000 + Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); + Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); + // 资金流水仍有(部分平仓也产生平仓费流水) + Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); + Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Console.WriteLine($"SD_007 通过:HasPartialUnWind={td.HasPartialUnWind},TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); + } + + // ================================================================ + // SD_008:SwapUnwind 含预付金 —— 两条资金流水(平仓费+应付预付金) + // ================================================================ + + /// + /// [SD_008] SwapUnwind 含预付金返还(SwapMarginAmount≠0) + /// ------------------------------------------------------------ + /// SwapUnwind 内部:SwapMarginAmount≠0 时追加一条"应付预付金"资金流水。 + /// 验证:2条流水(平仓费 + 应付预付金),金额和操作类型正确。 + /// + [TestMethod] + public void SD_008_SwapUnwind_含预付金_两条资金流水() + { + var td = CreateTrade(); + var service = new StubDealService(td); + var unwindData = CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 2000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + // 2条资金流水:平仓费 + 应付预付金 + Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费 -SwapRealizedPnL"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金 SwapMarginAmount"); + Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); + Console.WriteLine($"SD_008 通过:2条流水,平仓费={service.ClientCashCalls[0].amount},应付预付金={service.ClientCashCalls[1].amount} ✅"); + } + // ================================================================ // SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方) // ================================================================ From 4fede8339a7cf775cac0b48c770004364fa6f20a Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 18:12:52 +0800 Subject: [PATCH 03/88] =?UTF-8?q?refactor(test):=20SwapDealSettlementTest?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=BASwapUnwind/SwapIncome+=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=85=B1=E4=BA=ABstub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按业务拆分原 SwapDealSettlementTest(8场景)为3个文件,提升可读性: - TestableSwapDealService.cs: 共享stub(7个seam override)+工厂方法(CreateTrade/CreateUnwindData) - SwapUnwindScenarioTest.cs: 平仓全流程6场景(UW_001~006: 全平/部分平/含预付金/含费价重算/审核/提交) - SwapIncomeScenarioTest.cs: 结息2场景(SI_001~002: 正常结息/含预付金返息) 命名对齐 testable 分支风格(SwapUnwindScenarioTest),场景前缀改 UW_/SI_ 更直观。 删除 SwapDealSettlementTest.cs(内容已拆分迁移)。 SwapModule 173测试全绿,无回归。 --- .../SwapModule/SwapDealSettlementTest.cs | 426 ------------------ .../SwapModule/SwapIncomeScenarioTest.cs | 67 +++ .../SwapModule/SwapUnwindScenarioTest.cs | 191 ++++++++ .../SwapModule/TestableSwapDealService.cs | 127 ++++++ 4 files changed, 385 insertions(+), 426 deletions(-) delete mode 100644 UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs b/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs deleted file mode 100644 index a5c4b50a..00000000 --- a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs +++ /dev/null @@ -1,426 +0,0 @@ -using Newtonsoft.Json; -using YLErp.DBModels; -using YLErp.DBModels.Enums; - -namespace YLErp.Modules.SwapModule -{ - /// - /// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试 - /// ============================================================================ - /// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口, - /// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam - /// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。 - /// - /// 命名规范说明(见《互换价格字段命名规范决策文档》): - /// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释, - /// 标明其真实含义与规范名,让测试可读、可作规范示范。 - /// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice - /// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice - /// ============================================================================ - [TestClass] - public class SwapDealSettlementTest - { - private const int SwapTradeId = 7700; - private static readonly DateTime ValueDate = new(2026, 6, 15); - private static readonly DateTime UnwindDate = new(2026, 6, 16); - - #region Stub - - /// - /// 继承 SwapDealService,override 7 个 seam,把 DB/事务/外部服务替换为内存收集器。 - /// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。 - /// - private sealed class StubDealService : SwapDealService - { - private readonly trade _trade; - private readonly Dictionary _swapEvents; - private readonly Dictionary> _flowEventsByEventId; - public List<(double amount, string action, DateTime date)> ClientCashCalls = new(); - public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new(); - public int SaveAllChangesCount; - public int CloseReCheckCallCount; - - public StubDealService(trade td, - Dictionary swapEvents = null, - Dictionary> flowEventsByEventId = null) - : base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest)) - { - _trade = td; - _swapEvents = swapEvents ?? new Dictionary(); - _flowEventsByEventId = flowEventsByEventId ?? new Dictionary>(); - } - - protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; - - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) - { - ClientCashCalls.Add((amount, action, valueDate)); - return ClientCashCalls.Count; // 返回自增 id - } - - // 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库 - protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) - { - SaveSwapDealCalls.Add((unwindData, eventType, clientCashId)); - return SaveSwapDealCalls.Count; // 返回自增 eventId - } - - // ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType) - protected override swap_event FindSwapEvent(int tradeId, int eventType) - { - return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null; - } - - // ApproveSwapTrade 查事件关联流水:从内存字典取 - protected override List FindFlowEventsByEventId(long eventId) - { - return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List(); - } - - // ApplySwapTrade 的前置校验:计数,不实际执行 - protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) - { - CloseReCheckCallCount++; - } - - protected override void SaveAllChanges() { SaveAllChangesCount++; } - protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 - protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 - protected override void TriggerRealtimeSwapPosition() { } // 空操作 - } - - #endregion - - #region 数据构建 - - private static trade CreateTrade() - { - return new trade - { - id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888, - TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5), - ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用) - TradeStatus = "确认成交", ValidState = "Valid", - Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000 - }; - } - - /// 构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用) - private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m, - decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m, - decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m) - { - return new UnwindData - { - SwapTradeId = SwapTradeId, - SwapRealizedPnL = swapRealizedPnL, - SwapMarginRebatePnl = swapMarginRebatePnl, - SwapMarginAmount = swapMarginAmount, - SwapCloseAmount = swapRealizedPnL, - CloseMethod = closeMethod, - ClosePercent = closePercent, - CloseQty = closeQty, - CloseNotionalValue = closeNotionalValue, - PositionQty = positionQty, - ValueDate = ValueDate, - UnwindDate = UnwindDate, - StartDate = new DateTime(2026, 1, 5) - }; - } - - #endregion - - // ================================================================ - // SD_001:SwapIncome 正常结息 —— 验证资金流水金额正确 - // ================================================================ - - /// - /// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账: - /// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate) - /// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。 - /// - [TestMethod] - public void SD_001_SwapIncome_正常结息_资金流水金额正确() - { - var td = CreateTrade(); - td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 - var service = new StubDealService(td); - var unwindData = CreateUnwindData(swapRealizedPnL: 1000m); - - service.SwapIncome(unwindData); - - Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)"); - Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换"); - Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); - Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)"); - Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅"); - } - - // ================================================================ - // SD_002:SwapIncome 含预付金返息 —— 两条资金流水 - // ================================================================ - - /// - /// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 - /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200 - /// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。 - /// - [TestMethod] - public void SD_002_SwapIncome_含预付金返息_两条资金流水() - { - var td = CreateTrade(); - td.ExerciseDate = new DateTime(2026, 12, 31); - var service = new StubDealService(td); - var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m); - - service.SwapIncome(unwindData); - - Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)"); - Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); - Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl"); - Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); - Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅"); - } - - // ================================================================ - // SD_003:SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更 - // ================================================================ - - /// - /// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确 - /// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。 - /// - [TestMethod] - public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000 - var unwindData = CreateUnwindData( - swapRealizedPnL: 5000m, swapMarginAmount: 0m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 资金流水:平仓费 = -SwapRealizedPnL - Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水"); - Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); - // 持仓状态 - Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓"); - // 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1) - Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1)"); - // 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0 - Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0"); - Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0"); - // 事件类型 - Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)"); - Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); - } - - // ================================================================ - // SD_007:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 - // ================================================================ - - /// - /// [SD_007] SwapUnwind 部分平仓(借鉴 testable SwapUnwindScenarioTest.Scenario2) - /// ------------------------------------------------------------ - /// 部分平仓(CloseMethod≠全部平仓 且 ClosePercent≠1)走 else 分支: - /// td.HasPartialUnWind = 1,TradeStatus 保持"确认成交"不变 - /// StockEqvNotional/TradeAmount 仍按 CloseNotionalValue/CloseQty 扣减 - /// - [TestMethod] - public void SD_007_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 部分平仓:ClosePercent=0.5, CloseQty=5000, CloseNotionalValue=500000 - var unwindData = CreateUnwindData( - swapRealizedPnL: 3000m, swapMarginAmount: 0m, - closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, - closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 部分平仓:设 HasPartialUnWind=1,TradeStatus 不变 - Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); - Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); - // 持仓扣减:原 StockEqvNotional=1000000 - 500000 = 500000 - Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); - Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); - // 资金流水仍有(部分平仓也产生平仓费流水) - Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); - Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); - Console.WriteLine($"SD_007 通过:HasPartialUnWind={td.HasPartialUnWind},TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); - } - - // ================================================================ - // SD_008:SwapUnwind 含预付金 —— 两条资金流水(平仓费+应付预付金) - // ================================================================ - - /// - /// [SD_008] SwapUnwind 含预付金返还(SwapMarginAmount≠0) - /// ------------------------------------------------------------ - /// SwapUnwind 内部:SwapMarginAmount≠0 时追加一条"应付预付金"资金流水。 - /// 验证:2条流水(平仓费 + 应付预付金),金额和操作类型正确。 - /// - [TestMethod] - public void SD_008_SwapUnwind_含预付金_两条资金流水() - { - var td = CreateTrade(); - var service = new StubDealService(td); - var unwindData = CreateUnwindData( - swapRealizedPnL: 5000m, swapMarginAmount: 2000m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 2条资金流水:平仓费 + 应付预付金 - Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); - Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费 -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); - Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金 SwapMarginAmount"); - Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); - Console.WriteLine($"SD_008 通过:2条流水,平仓费={service.ClientCashCalls[0].amount},应付预付金={service.ClientCashCalls[1].amount} ✅"); - } - - // ================================================================ - // SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方) - // ================================================================ - - /// - /// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725) - /// ------------------------------------------------------------ - /// 平仓事件重算三个字段(规范语义,见命名文档): - /// TradingAmountFeeAvg(ExitDirtyFeePrice)= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio - /// TradingAmountNetFeeAvg(ExitCleanFeePrice)= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio - /// TradingAmount = TradingAmountAvg × CloseQty - /// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。 - /// - /// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1) - /// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97 - /// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95 - /// TradingAmount = 1.02 × 1000 = 1020 - /// - [TestMethod] - public void SD_004_DealFloatPosition_含费价重算正确() - { - var td = CreateTrade(); - var service = new StubDealService(td); - - // 构造平仓事件(PositionType>0 触发重算) - var closeEvent = new swap_flow_event - { - EventType = (int)SwapEventTypeEnum.平仓, - PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1 - // TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice - TradingAmountAvg = 1.02m, - // TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice - TradingAmountNetAvg = 1.00m, - TradingFeePending = 50m, - }; - var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); - unwindData.FlowEvents.Add(closeEvent); - - service.SwapUnwind(unwindData); - - // ExitDirtyFeePrice(TradingAmountFeeAvg)= 1.02 + 50/1000×(-1) = 0.97 - Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, - $"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}"); - // ExitCleanFeePrice(TradingAmountNetFeeAvg)= 1.00 + 50/1000×(-1) = 0.95 - Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, - $"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}"); - // TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020 - Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, - $"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}"); - Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg},ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg},TradingAmount={closeEvent.TradingAmount} ✅"); - } - - // ================================================================ - // SD_005:ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态 - // ================================================================ - - /// - /// [SD_005] ApproveSwapTrade 审核通过全部平仓 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData, - /// 据此生成资金流水 + 更新持仓状态。 - /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证: - /// - SwapRealizedPnL 从事件反序列化正确(EventData JSON) - /// - 资金流水金额 = -SwapRealizedPnL - /// - 全平仓 → TradeStatus=已平仓 - /// - [TestMethod] - public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() - { - var td = CreateTrade(); - // 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000) - var unwindData = CreateUnwindData(swapRealizedPnL: 8000m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m); - var swapEvent = new swap_event - { - id = 1, SwapTradeId = SwapTradeId, - EventType = (int)SwapEventTypeEnum.平仓, Invalid = false, - EventData = JsonConvert.SerializeObject(unwindData) - }; - var flowEvents = new Dictionary> - { - [1] = new List { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } } - }; - var service = new StubDealService(td, - swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, - flowEventsByEventId: flowEvents); - - service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); - - // 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000 - Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水"); - Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL"); - // 持仓状态 - Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓"); - Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount},TradeStatus={td.TradeStatus} ✅"); - } - - // ================================================================ - // SD_006:ApplySwapTrade 提交审核 —— 前置校验 + 保存事件 - // ================================================================ - - /// - /// [SD_006] ApplySwapTrade 提交审核 - /// ------------------------------------------------------------ - /// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。 - /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证: - /// - CloseReCheckSetTrade 被调用1次 - /// - SaveSwapDeal 以 approve=true 调用(事件类型正确) - /// - SwapRealizedPnL = SwapCloseAmount(ApplySwapTrade 内部赋值) - /// - [TestMethod] - public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0 - var unwindData = CreateUnwindData(swapRealizedPnL: 0m); - unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额 - - service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); - - // 前置校验被调用 - Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); - // SaveSwapDeal 以 approve=true 调用 - Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); - Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); - // SwapRealizedPnL 应被赋值为 SwapCloseAmount(ApplySwapTrade 内部 cs:1631) - Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, - "SwapRealizedPnL 应=SwapCloseAmount(6000)"); - Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); - } - } -} diff --git a/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs new file mode 100644 index 00000000..5a1d4308 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs @@ -0,0 +1,67 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换结息(SwapIncome)测试 + /// ============================================================================ + /// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。 + /// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。 + /// ============================================================================ + [TestClass] + public class SwapIncomeScenarioTest + { + // ================================================================ + // 场景1:SwapIncome 正常结息 —— 资金流水金额正确 + // ================================================================ + + /// + /// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。 + /// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。 + /// + [TestMethod] + public void SI_001_SwapIncome_正常结息_资金流水金额正确() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m); + + service.SwapIncome(unwindData); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)"); + Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换"); + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); + Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)"); + Console.WriteLine($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅"); + } + + // ================================================================ + // 场景2:SwapIncome 含预付金返息 —— 两条资金流水 + // ================================================================ + + /// + /// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 + /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。 + /// + [TestMethod] + public void SI_002_SwapIncome_含预付金返息_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m); + + service.SwapIncome(unwindData); + + Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)"); + Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); + Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息"); + Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); + Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs new file mode 100644 index 00000000..103b65f5 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -0,0 +1,191 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition) + /// ============================================================================ + /// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。 + /// 命名规范说明(见《互换价格字段命名规范决策文档》): + /// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice + /// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice + /// ============================================================================ + [TestClass] + public class SwapUnwindScenarioTest + { + // ================================================================ + // 场景1:SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓 + // ================================================================ + + [TestMethod] + public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓"); + Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind"); + Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0"); + Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)"); + Console.WriteLine($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅"); + } + + // ================================================================ + // 场景2:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 + // ================================================================ + + [TestMethod] + public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 3000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, + closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); + Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); + Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); + Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景3:SwapUnwind 含预付金 —— 两条资金流水 + // ================================================================ + + [TestMethod] + public void UW_003_SwapUnwind_含预付金_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 2000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金"); + Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); + Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅"); + } + + // ================================================================ + // 场景4:DealFloatPosition 含费价重算(后端唯二真做计算的地方) + // ================================================================ + + /// + /// 平仓事件重算三字段(SwapDealService DealFloatPosition): + /// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio + /// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio + /// TradingAmount = TradingAmountAvg × CloseQty + /// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1) + /// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97 + /// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95 + /// TradingAmount = 1.02 × 1000 = 1020 + /// + [TestMethod] + public void UW_004_DealFloatPosition_含费价重算正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var closeEvent = new swap_flow_event + { + EventType = (int)SwapEventTypeEnum.平仓, + PositionType = (int)PositionTypeFlag.Long, + TradingAmountAvg = 1.02m, // ExitDirtyPrice + TradingAmountNetAvg = 1.00m, // ExitCleanPrice + TradingFeePending = 50m, + }; + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); + unwindData.FlowEvents.Add(closeEvent); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, + $"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97"); + Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, + $"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95"); + Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, + $"TradingAmount=ExitDirtyPrice×CloseQty=1020"); + Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅"); + } + + // ================================================================ + // 场景5:ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账 + // ================================================================ + + [TestMethod] + public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m); + var swapEvent = new swap_event + { + id = 1, SwapTradeId = SwapDealTestFactory.SwapTradeId, + EventType = (int)SwapEventTypeEnum.平仓, Invalid = false, + EventData = JsonConvert.SerializeObject(unwindData) + }; + var flowEvents = new Dictionary> + { + [1] = new List { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } } + }; + var service = new TestableSwapDealService(td, + swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, + flowEventsByEventId: flowEvents); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水"); + Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL"); + Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓"); + Console.WriteLine($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件 + // ================================================================ + + [TestMethod] + public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m); + unwindData.SwapCloseAmount = 6000m; + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); + Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, + "SwapRealizedPnL 应=SwapCloseAmount(6000)"); + Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs new file mode 100644 index 00000000..8b404f7e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -0,0 +1,127 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapDealService 的可测试化子类(共享 stub)。 + /// 继承 SwapDealService,override seam 把 DB/事务/外部服务替换为内存收集器。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。 + /// + public class TestableSwapDealService : SwapDealService + { + private readonly trade _trade; + private readonly Dictionary _swapEvents; + private readonly Dictionary> _flowEventsByEventId; + + /// 捕获 AddClientCash 的每次调用(金额, 操作, 日期) + public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new(); + + /// 捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId) + public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new(); + + public int SaveAllChangesCount; + public int CloseReCheckCallCount; + + public TestableSwapDealService(trade td, + Dictionary swapEvents = null, + Dictionary> flowEventsByEventId = null) + : base(new OptUserInfo(0, nameof(TestableSwapDealService), OptUserFrom.UnitTest)) + { + _trade = td; + _swapEvents = swapEvents ?? new Dictionary(); + _flowEventsByEventId = flowEventsByEventId ?? new Dictionary>(); + } + + protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; + + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action, valueDate)); + return ClientCashCalls.Count; // 返回自增 id + } + + // 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库 + protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) + { + SaveSwapDealCalls.Add((unwindData, eventType, clientCashId)); + return SaveSwapDealCalls.Count; // 返回自增 eventId + } + + // ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType) + protected override swap_event FindSwapEvent(int tradeId, int eventType) + { + return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null; + } + + // ApproveSwapTrade 查事件关联流水:从内存字典取 + protected override List FindFlowEventsByEventId(long eventId) + { + return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List(); + } + + // ApplySwapTrade 的前置校验:计数,不实际执行 + protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) + { + CloseReCheckCallCount++; + } + + protected override void SaveAllChanges() { SaveAllChangesCount++; } + protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 + protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 + protected override void TriggerRealtimeSwapPosition() { } // 空操作 + } + + /// + /// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。 + /// + public static class SwapDealTestFactory + { + public const int SwapTradeId = 7700; + public static readonly DateTime ValueDate = new(2026, 6, 15); + public static readonly DateTime UnwindDate = new(2026, 6, 16); + + public static trade CreateTrade() + { + return new trade + { + id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888, + TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5), + ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用) + TradeStatus = "确认成交", ValidState = "Valid", + Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000 + }; + } + + /// 构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用) + public static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m, + decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m, + decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m) + { + return new UnwindData + { + SwapTradeId = SwapTradeId, + SwapRealizedPnL = swapRealizedPnL, + SwapMarginRebatePnl = swapMarginRebatePnl, + SwapMarginAmount = swapMarginAmount, + SwapCloseAmount = swapRealizedPnL, + CloseMethod = closeMethod, + ClosePercent = closePercent, + CloseQty = closeQty, + CloseNotionalValue = closeNotionalValue, + PositionQty = positionQty, + ValueDate = ValueDate, + UnwindDate = UnwindDate, + StartDate = new DateTime(2026, 1, 5) + }; + } + + public static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") + { + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + } +} From 28edb057c77c0401ce882440f0fbe470bbc7b137 Mon Sep 17 00:00:00 2001 From: tengyufan <1532636164@qq.com> Date: Fri, 3 Jul 2026 18:25:40 +0800 Subject: [PATCH 04/88] =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Controllers/calendarController.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/YLErpWeb/Controllers/calendarController.cs b/YLErpWeb/Controllers/calendarController.cs index e37905cf..2283634e 100644 --- a/YLErpWeb/Controllers/calendarController.cs +++ b/YLErpWeb/Controllers/calendarController.cs @@ -296,5 +296,13 @@ namespace YLErp.Web.Controllers CalendarBLL.ResetCalendarForQdp(); return JsonSuccess("删除成功"); } + + [HttpPost] + public JsonResult Reset() + { + CalendarBLL.IsListOld = true; + CalendarBLL.ResetCalendarForQdp(); + return JsonSuccess("重置成功"); + } } } From cbb5c88aa879e1a4a9ead7dd12ed366e63b82814 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 18:30:33 +0800 Subject: [PATCH 05/88] =?UTF-8?q?refactor(swap):=20SwapPositionCompose?= =?UTF-8?q?=E8=A1=A55=E4=B8=AAseam+=E4=BA=8B=E5=8A=A1=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BAExecuteInTransaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapEodPositionService 新增5个 protected virtual seam(SwapPositionCompose路径): - FindActiveSwapTrades(DateTime, IEnumerable): 查活跃互换交易 - FindAllSwapPositions(List): 查所有持仓(含初始+实际) - FindTradeExtends(List): 批量查交易扩展 - FindEodSwapsByDate(DateTime): 查指定日期日终汇总 - FindFlowEvents(int, DateTime): 查交易指定日期的完成流水事件 SwapPositionCompose 方法体重构: - 5处内联DbContext查询替换为seam调用 - 事务(BeginTransaction/Commit/Rollback)重构为ExecuteInTransaction lambda - SaveChanges替换为SaveAllChanges seam - 复用已有FindEodSwapPositions seam(替代内联eod_swap_position查询) 为零行为变更(seam生产实现=原代码,ExecuteInTransaction=原事务逻辑)。 SwapModule 173测试全绿,无回归。为SwapPositionComposeScenarioTest铺路。 --- .../SwapModule/SwapEodPositionService.cs | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 9007286c..4f885bb4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -167,6 +167,51 @@ namespace YLErp.Modules.SwapModule return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); } + // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- + + /// 查找收盘所需的活跃互换交易(生产: DbContext.trade.Where;测试: 内存列表) + protected virtual List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) + { + var tradePredicate = PredicateBuilder.Create(n => n.ValidState != ConsGlobal.InValid + && n.TradeType == "收益互换" + && n.TradeDate <= settleDate + && n.ExerciseDate >= settleDate + && (n.TradeStatus == ConsTrade.确认成交 || n.UnWindDate >= settleDate) + ); + if (clientIds != null && clientIds.Any()) + { + tradePredicate = tradePredicate.And(x => clientIds.Contains(x.ClientId)); + } + return DbContext.trade.Where(tradePredicate).ToList(); + } + + /// 查找交易的所有持仓(含初始+实际,生产: DbContext.swap_position;测试: 内存列表) + protected virtual List FindAllSwapPositions(List tradeIds) + { + return DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList(); + } + + /// 批量查找交易扩展(生产: DbContext.trade_extend;测试: 内存列表) + protected virtual List FindTradeExtends(List tradeIds) + { + return DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); + } + + /// 查找指定日期的日终汇总(生产: DbContext.eod_swap;测试: 内存列表) + protected virtual List FindEodSwapsByDate(DateTime valueDate) + { + return DbContext.eod_swap.Where(x => x.ValueDate == valueDate).ToList(); + } + + /// 查找交易在指定日期的完成流水事件(生产: DbContext.swap_flow_event;测试: 内存列表) + protected virtual List FindFlowEvents(int swapTradeId, DateTime settleDate) + { + Expression> eventExpression = x => x.SwapTradeId == swapTradeId + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate == settleDate; + return DbContext.swap_flow_event.Where(eventExpression).ToList(); + } + #endregion /// @@ -205,28 +250,17 @@ namespace YLErp.Modules.SwapModule { var dateStr = settleDate.ToString("yyyy-MM-dd"); Log.Info("SwapPositionCompose:" + "settleDate:" + settleDate + " preSettleDate:" + preSettleDate + " ClientIds:" + JsonHelper.Serialize(ClientIds)); - var tradePredicate = PredicateBuilder.Create(n => n.ValidState != ConsGlobal.InValid - && n.TradeType == "收益互换" - && n.TradeDate <= settleDate - && n.ExerciseDate >= settleDate - && (n.TradeStatus == ConsTrade.确认成交 || n.UnWindDate >= settleDate) - ); - if (ClientIds != null && ClientIds.Any()) - { - tradePredicate = tradePredicate.And(x => ClientIds.Contains(x.ClientId)); - } - var tradeQueryList = DbContext.trade.Where(tradePredicate).ToList(); + var tradeQueryList = FindActiveSwapTrades(settleDate, ClientIds); var tradeIds = tradeQueryList.Select(s => s.id).ToList(); - var allTradePositionList = DbContext.swap_position.Where(t => tradeIds.Contains(t.SwapTradeId) && !t.Invalid).ToList(); + var allTradePositionList = FindAllSwapPositions(tradeIds); var tradePositionList = allTradePositionList.Where(t => t.IsInitial).ToList(); var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList(); - var tradeExtendList = DbContext.trade_extend.Where(x => tradeIds.Contains(x.TradeId)).ToList(); - var eodSwapList = DbContext.eod_swap.Where(x => x.ValueDate == preSettleDate).ToList(); + var tradeExtendList = FindTradeExtends(tradeIds); + var eodSwapList = FindEodSwapsByDate(preSettleDate); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { - var trans = DbContext.Database.BeginTransaction(); - try + ExecuteInTransaction(() => { List removeEventTyps = new List() { (int)SwapEventTypeEnum.自动互换 }; bool longShort = td.StructureType == ClientMarginTypeEnum.多空组合.ToString(); @@ -243,7 +277,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception($"交易{td.TradeNumber}在上一交易日【{preSettleDate:yyyy-MM-dd}】未收盘"); } - var allEodPositions = DbContext.eod_swap_position.Where(x => x.ValueDate >= preSettleDate && x.SwapTradeId == td.id && !x.Invalid); + var allEodPositions = FindEodSwapPositions(td.id, preSettleDate); var eodPositions = allEodPositions.Where(x => x.ValueDate == preSettleDate).ToList();//上一日终持仓信息 @@ -256,18 +290,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception($"交易【{td.TradeNumber}】到期扔有持仓信息"); } - var flowEvents = new List(); - Expression> eventExpression = x => x.SwapTradeId == td.id && x.DataState == (int)SwapFlowDateStateEnum.完成; - eventExpression = eventExpression.And(x => x.EventDate == settleDate); - //if (settleDate == td.TradeDate) - //{ - // eventExpression = eventExpression.And(x => x.EventDate == settleDate); - //} - //else - //{ - // eventExpression = eventExpression.And(x => x.UnwindDate == settleDate); - //} - flowEvents = DbContext.swap_flow_event.Where(eventExpression).ToList(); + var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 //处理浮动腿 @@ -296,18 +319,8 @@ namespace YLErp.Modules.SwapModule td.TradeStatus = "已到期"; td.UnWindDate = settleDate; } - DbContext.SaveChanges(); - trans.Commit(); - } - catch (Exception ex) - { - trans.Rollback(); - throw new Exception(ex.Message, ex); - } - finally - { - trans.Dispose(); - } + SaveAllChanges(); + }); } } From 86825cb2a1cb290806ae718539bb97a5e196ff6b Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 18:37:46 +0800 Subject: [PATCH 06/88] =?UTF-8?q?test(swap):=20SwapPositionComposeScenario?= =?UTF-8?q?Test(4=E5=9C=BA=E6=99=AF)+CopyEodPosition/UpdateEodPosition/Sav?= =?UTF-8?q?eCurrentEodInitalPosi=E8=B5=B0PersistEodSwapPosition=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写4场景: - SPC_001 首次归档(无前日eod):PosiQuantity=初始持仓 - SPC_002 Copy分支(有前日eod无事件):PosiGrossPrice原样复制 - SPC_003 Update分支(有平仓事件):PosiQuantity=1000-400=600, TdCloseQty=400 - SPC_004 未收盘抛异常(非交易首日无前日eod) SwapEodPositionService 改动(零行为变更): - CopyEodPosition/UpdateEodPosition/SaveCurrentEodInitalPosi 的 DbContext.eod_swap_position.Add 改为调 PersistEodSwapPosition seam(3处),使测试可拦截持久化 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。 SwapModule 177测试全绿(+4),无回归。 --- .../SwapPositionComposeScenarioTest.cs | 259 ++++++++++++++++++ .../SwapModule/SwapEodPositionService.cs | 6 +- 2 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs new file mode 100644 index 00000000..86c3dac1 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -0,0 +1,259 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapPositionCompose 日终归档端到端测试 + /// ============================================================================ + /// 借鉴 testable 分支 SwapPositionComposeScenarioTest,基于当前分支 seam 重写。 + /// 覆盖 DealFloatPositions 的首次归档/Copy/Update/异常路径。 + /// 利息腿场景(自动互换)因 CalcSwapInterests 参数适配复杂留后续。 + /// ============================================================================ + [TestClass] + public class SwapPositionComposeScenarioTest + { + private const int SwapTradeId = 100; + private static readonly DateTime SettleDate = new(2025, 4, 24); + private static readonly DateTime PreSettleDate = new(2025, 4, 23); + + #region 可测试化子类 + + /// + /// 继承 SwapEodPositionService,override SwapPositionCompose 路径上的 seam。 + /// 适配当前分支 seam 签名(GetUnderlyingPrice 带 out、GetCurrencyRate 返回 double 等)。 + /// + private sealed class TestableSwapEodService : SwapEodPositionService + { + private readonly List _trades; + private readonly List _positions; + private readonly List _eodPositions; + private readonly List _eodSwaps; + private readonly List _extends; + private readonly List _flowEvents; + private readonly decimal _price; + private readonly decimal _vobp; + + public List CreatedEodPositions { get; } = new(); + public List<(double amount, string action)> ClientCashCalls { get; } = new(); + + public TestableSwapEodService( + List trades, List positions, + List eodPositions, List eodSwaps, + List extends, List flowEvents, + decimal price = 100m, decimal vobp = 0m) + : base(new OptUserInfo(0, nameof(SwapPositionComposeScenarioTest), OptUserFrom.UnitTest)) + { + _trades = trades; _positions = positions; _eodPositions = eodPositions; + _eodSwaps = eodSwaps; _extends = extends; _flowEvents = flowEvents; + _price = price; _vobp = vobp; + } + + // SwapPositionCompose 路径 seam override + protected override List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) => _trades; + protected override List FindAllSwapPositions(List tradeIds) => _positions; + protected override List FindTradeExtends(List tradeIds) => _extends; + protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; + protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; + protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) + => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); + protected override List FindSwapPositions(int swapTradeId) + => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + + // DealFloatPositions 路径 seam override + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = _vobp; return _price; } + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + // 持久化/事务 seam override + protected override void PersistEodSwapPosition(eod_swap_position position) { CreatedEodPositions.Add(position); } + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } + protected override void SaveAllChanges() { } + protected override void ExecuteInTransaction(Action action) => action(); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) + { ClientCashCalls.Add((amount, action)); return ClientCashCalls.Count; } + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } + public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) + { return new swap_event { id = 1 }; } + protected override List CalcSwapInterests( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, + decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + List closeList = null) => new List(); + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0; + + public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) + => SwapPositionCompose(settleDate, preSettleDate, null); + } + + #endregion + + #region 工厂方法 + + private static trade CreateTrade(DateTime? startDate = null) + { + var date = startDate ?? SettleDate; + return new trade + { + id = SwapTradeId, TradeNumber = "TEST-COMPOSE-001", ClientId = 10, + TradeType = "收益互换", TradeDate = date, StartDate = date, + ExerciseDate = SettleDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", StructureType = "普通债券类收益互换", + OriginalStockEqvNotional = 100000, TradePrice = 0 + }; + } + + private static trade_extend CreateExtend() + { + return new trade_extend + { + TradeId = SwapTradeId, + ExtendJson = @"{""NeedOpenFee"":false,""AnnualDays"":365,""SettlementRules"":0,""Direction"":1,""FlowBookMode"":0}" + }; + } + + private static swap_position CreateFloatPosition(long positionId, decimal qty) + { + return new swap_position + { + id = positionId, SwapTradeId = SwapTradeId, PositionId = positionId, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "220205.IB", UnderlyingInstrumentType = "TBonds", + ContractSize = 1m, CountRatio = 1m, IsInitial = true, Invalid = false, + PosiQuantity = qty, PosiNotionalValue = qty, + PosiNetPrice = 1.0050m, PosiGrossPrice = 1.0020m, + PosiNetFeePrice = 1.0000m, PosiNetNoFeePrice = 0.9970m, + InterestDirection = 0 + }; + } + + private static eod_swap_position CreateFloatEodPosition(long positionId, decimal qty, decimal grossPrice) + { + return new eod_swap_position + { + SwapTradeId = SwapTradeId, PositionId = positionId, ValueDate = PreSettleDate, + PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, + PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, + PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, + UnderlyingCode = "220205.IB", ContractSize = 1m, + InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty + }; + } + + private static swap_flow_event CreateCloseFlowEvent(long positionId, decimal qty) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, PositionId = positionId, + EventType = (int)SwapFlowEventTypeEnum.平仓, + Quantity = qty, EventDate = SettleDate, UnwindDate = SettleDate, + MarkClosePnl = 500m, CloseFee = 10m, DividendIn = 5m, + TradingAmountAvg = 1.0030m, DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + + #endregion + + // ================================================================ + // 场景1:首次归档(无前日eod,交易首日) + // ================================================================ + + [TestMethod] + public void SPC_001_首次归档_无前日Eod_直接取初始持仓() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + Assert.IsTrue(service.CreatedEodPositions.Count >= 1, "应创建至少1条eod"); + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod, "应创建浮动腿持仓"); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "首次归档 PosiQuantity=初始持仓数量"); + Console.WriteLine($"SPC_001: PosiQuantity={floatEod.PosiQuantity} ✅"); + } + + // ================================================================ + // 场景2:有前日eod无事件 → Copy + // ================================================================ + + [TestMethod] + public void SPC_002_Copy分支_有前日Eod无事件_价格原样复制() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(1000m, floatEod.PosiQuantity, "Copy分支 PosiQuantity不变"); + Assert.AreEqual(1.0020m, floatEod.PosiGrossPrice, "Copy分支 PosiGrossPrice从前日eod复制"); + Console.WriteLine($"SPC_002: PosiQuantity={floatEod.PosiQuantity}, PosiGrossPrice={floatEod.PosiGrossPrice} ✅"); + } + + // ================================================================ + // 场景3:有平仓事件 → Update(持仓扣减) + // ================================================================ + + [TestMethod] + public void SPC_003_Update分支_有平仓事件_持仓扣减() + { + var td = CreateTrade(); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var prevEod = new List { CreateFloatEodPosition(1, 1000, 1.0020m) }; + var flowEvents = new List { CreateCloseFlowEvent(1, 400) }; + var service = new TestableSwapEodService( + new List { td }, positions, + prevEod, new List(), + new List { extend }, flowEvents); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var floatEod = service.CreatedEodPositions.FirstOrDefault(x => x.PositionId == 1); + Assert.IsNotNull(floatEod); + Assert.AreEqual(600m, floatEod.PosiQuantity, "Update分支 PosiQuantity=1000-400=600"); + Assert.AreEqual(400m, floatEod.TdCloseQty, "TdCloseQty=平仓数量400"); + Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); + } + + // ================================================================ + // 场景4:未收盘抛异常 + // ================================================================ + + [TestMethod] + public void SPC_004_未收盘_非交易首日无前日Eod_抛异常() + { + // 交易起始日早于收盘日(非交易首日),且无前日eod + var td = CreateTrade(startDate: SettleDate.AddDays(-10)); + var extend = CreateExtend(); + var positions = new List { CreateFloatPosition(1, 1000) }; + var service = new TestableSwapEodService( + new List { td }, positions, + new List(), new List(), + new List { extend }, new List()); + + var ex = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}"); + Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 4f885bb4..b313e99c 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1563,7 +1563,7 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } @@ -1660,7 +1660,7 @@ namespace YLErp.Modules.SwapModule curretEod.Invalid = false; if (curretEod.id == 0) { - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); } return curretEod; } @@ -1834,7 +1834,7 @@ namespace YLErp.Modules.SwapModule curretEod.TdCurrency = Convert.ToDecimal(currencyRate); UpdateDbOption(curretEod); curretEod.Invalid = false; - DbContext.eod_swap_position.Add(curretEod); + PersistEodSwapPosition(curretEod); return curretEod; } /// From 6835a593b0679238f0361e109bcb137309a69c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Mon, 6 Jul 2026 15:20:21 +0800 Subject: [PATCH 07/88] =?UTF-8?q?feat(swaptrade):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=94=B6=E7=9B=8A=E7=BB=93=E7=AE=97=E6=97=A5=E6=9C=9F=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=92=8C=E9=99=90=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MaxIncomeValueDate 属性用于限制收益结算日期上限 - 实现 parseLocalDate 函数用于解析本地日期格式 - 在前端添加日期选择器的最大日期限制和验证逻辑 - 添加 isAfterMaxIncomeValueDate 和 validateIncomeValueDate 方法进行日期验证 - 在后端添加 GetMaxIncomeValueDate 方法计算最大收益结算日期 - 实现 ValidateIncomeValueDate 对收益结算日期进行服务器端验证 - 添加 NormalizeIncomeUnwindDate 方法统一处理平仓日期 - 在模型中新增 MaxIncomeValueDate 非映射属性用于前端显示 - 更新日期格式化函数确保日期字符串的一致性处理 --- Framework/YLErp.Core/DBModels/SwapEvent.cs | 5 ++ .../Modules/SwapModule/SwapDealService.cs | 37 ++++++++++++++- YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml | 15 ++++-- .../Scripts/app/swaptrade/incomeSwapTrade.js | 46 ++++++++++++++++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index ce7f1cc2..eff65bac 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -130,6 +130,11 @@ namespace YLErp.DBModels /// public DateTime ValueDate { get; set; } /// + /// 收益结算日期上限 + /// + [NotMapped] + public DateTime? MaxIncomeValueDate { get; set; } + /// /// 平仓/互换日期 /// public DateTime? UnwindDate { get; set; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 8c557d4b..48b18a45 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -113,6 +113,11 @@ namespace YLErp.Modules.SwapModule new TradeUnwindService(this).CloseReCheck_SetTrade(swapTradeId, isSwap, needCheck); } + protected virtual DateTime GetMaxIncomeValueDate(trade td) + { + return QdpCalendarHelper.GetNonHolidayDefore(td.ExerciseDate.Value.AddDays(-1)); + } + #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) @@ -394,7 +399,8 @@ namespace YLErp.Modules.SwapModule var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; - var dealDate = valuedateBLL.ValueDate < td.ExerciseDate.Value ? valuedateBLL.ValueDate : td.ExerciseDate.Value; + var maxIncomeValueDate = GetMaxIncomeValueDate(td); + var dealDate = valuedateBLL.ValueDate.Date > maxIncomeValueDate.Date ? maxIncomeValueDate : valuedateBLL.ValueDate; // 收益结算不检查收盘限制 var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; @@ -425,7 +431,7 @@ namespace YLErp.Modules.SwapModule unwindData.UnwindDate = dealDate; floatEvent.UnwindDate = unwindData.UnwindDate; floatEvent.EventDate = dealDate; - unwindData.PayDate = QdpCalendarHelper.GetNonHoliday(unwindData.UnwindDate.Value.AddDays(td.trade_extend.ExtendObj.SettlementRules)); + unwindData.PayDate = valuedateBLL.ValueDate; floatEvent.PayDate = unwindData.PayDate; floatEvent.SwapTradeId = tradeId; floatEvent.SwapTradeNo = td.TradeNumber; @@ -464,6 +470,7 @@ namespace YLErp.Modules.SwapModule } unwindData.FlowEvents.Add(floatEvent); } + unwindData.MaxIncomeValueDate = maxIncomeValueDate; return unwindData; } /// @@ -1645,6 +1652,8 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeIncomeUnwindDate(unwindData); + ValidateIncomeValueDate(unwindData, td); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易 ExecuteInTransaction(() => @@ -1683,6 +1692,11 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); + if (eventType == (int)SwapEventTypeEnum.互换) + { + NormalizeIncomeUnwindDate(swapEvent.unwindData); + ValidateIncomeValueDate(swapEvent.unwindData, td); + } var flowList = FindFlowEventsByEventId(swapEvent.id); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate); @@ -1726,6 +1740,11 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + if (eventType == (int)SwapEventTypeEnum.互换) + { + NormalizeIncomeUnwindDate(unwindData); + ValidateIncomeValueDate(unwindData, td); + } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; ExecuteInTransaction(() => @@ -1735,6 +1754,20 @@ namespace YLErp.Modules.SwapModule SaveAllChanges(); }); } + private void ValidateIncomeValueDate(UnwindData unwindData, trade td) + { + var maxIncomeValueDate = GetMaxIncomeValueDate(td).Date; + if (unwindData.ValueDate.Date > maxIncomeValueDate) + { + throw new ServiceException($"手动互换结算日期不能晚于当前交易结束日期T-1:{maxIncomeValueDate:yyyy-MM-dd}"); + } + } + + private void NormalizeIncomeUnwindDate(UnwindData unwindData) + { + unwindData.UnwindDate = unwindData.ValueDate; + } + /// /// 保存平仓/互换事件 /// diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index 8084253a..578d264e 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -11,10 +11,17 @@ @section JS { @@ -45,7 +52,7 @@ 起始日期 {{deal.StartDate}} 收益结算日期 - + 支付日期 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index 019e302f..09bcf96b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -9,6 +9,19 @@ const inputFormatDividend = Object.freeze({ precision: 2, append: '', negative: const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' }); const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true }); let ValueDate = model.ValueDate; +let MaxIncomeValueDate = model.MaxIncomeValueDate ? model.MaxIncomeValueDate.substr(0, 10) : ValueDate; + +function parseLocalDate(value) { + if (!value) { + return null; + } + var parts = value.substr(0, 10).split('-'); + if (parts.length !== 3) { + return null; + } + return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); +} + const vue = new Vue({ el: '#vueDiv', data: { @@ -22,7 +35,7 @@ const vue = new Vue({ }, computed: { maxUnwindDate() { - return ValueDate; + return MaxIncomeValueDate; }, minStartDate() { return this.deal.StartDate; @@ -33,7 +46,32 @@ const vue = new Vue({ this.initDeal(); this.setValueDate(); }, + mounted() { + this.$nextTick(() => { + var $incomeValueDatePicker = $(this.$refs.incomeValueDatePicker.$el); + $incomeValueDatePicker.datepicker("option", "maxDate", parseLocalDate(MaxIncomeValueDate)); + if (this.isAfterMaxIncomeValueDate(this.deal.ValueDate)) { + this.validateIncomeValueDate(this.deal.ValueDate); + } else { + $incomeValueDatePicker.val(this.deal.ValueDate); + } + }); + }, methods: { + isAfterMaxIncomeValueDate(valueDate) { + return valueDate && MaxIncomeValueDate && valueDate > MaxIncomeValueDate; + }, + validateIncomeValueDate(valueDate) { + if (!this.isAfterMaxIncomeValueDate(valueDate)) { + return true; + } + main.message("手动互换结算日期不能晚于当前交易结束日期T-1:" + MaxIncomeValueDate); + this.deal.ValueDate = MaxIncomeValueDate; + this.deal.UnwindDate = MaxIncomeValueDate; + this.floatPosition.UnwindDate = MaxIncomeValueDate; + $(this.$refs.incomeValueDatePicker.$el).val(MaxIncomeValueDate); + return false; + }, getPriceScale() { return this.multiplier == 100 ? 0.01 : 1; }, @@ -93,6 +131,9 @@ const vue = new Vue({ }, setValueDate(e) {//修改平仓日期 if (e) { + if (!this.validateIncomeValueDate(e)) { + e = MaxIncomeValueDate; + } this.deal.ValueDate = e; this.deal.UnwindDate = e; this.floatPosition.UnwindDate = e; @@ -212,6 +253,9 @@ const vue = new Vue({ main.message("请输入平仓日期"); return; } + if (!thisObj.validateIncomeValueDate(thisObj.deal.ValueDate)) { + return; + } let reqObj = _.cloneDeep(thisObj.deal); let marginCloneList = _.cloneDeep(thisObj.marginList); reqObj.FlowEvents = _.cloneDeep(thisObj.interestList); From 05d3e9bc5248e50eecffdc94dad495c442ef9c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Mon, 6 Jul 2026 16:49:11 +0800 Subject: [PATCH 08/88] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E6=94=B6?= =?UTF-8?q?=E5=85=A5=E5=80=BC=E6=97=A5=E6=9C=9F=E8=AE=A1=E7=AE=97=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除非节假日调整逻辑,直接使用行使日期前一天作为最大收入值日期 - 简化日期计算流程,避免节假日调整导致的潜在错误 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 48b18a45..16730262 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -115,7 +115,7 @@ namespace YLErp.Modules.SwapModule protected virtual DateTime GetMaxIncomeValueDate(trade td) { - return QdpCalendarHelper.GetNonHolidayDefore(td.ExerciseDate.Value.AddDays(-1)); + return td.ExerciseDate.Value.AddDays(-1); } #endregion From ddac1d8e11e7cdd55e81e32cc1b188d2cc81f834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Mon, 6 Jul 2026 16:58:50 +0800 Subject: [PATCH 09/88] =?UTF-8?q?fix(swap-trade):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=94=B6=E7=9B=8A=E7=BB=93=E7=AE=97=E6=97=A5=E6=9C=9F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E8=8A=82=E5=81=87=E6=97=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将datepicker组件的holiday属性改为noholiday并设置为true - 解决了收益结算日期选择器中节假日标记显示异常的问题 --- YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index 578d264e..80e6ce7a 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -52,7 +52,7 @@ 起始日期 {{deal.StartDate}} 收益结算日期 - + 支付日期 From 7f64d486993c9020dfe6b735664df19375d34232 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 7 Jul 2026 12:30:19 +0800 Subject: [PATCH 10/88] =?UTF-8?q?test(fe):=20=E6=8E=A5=E5=85=A5=20SwapCalc?= =?UTF-8?q?=20=E7=BA=AF=E8=AE=A1=E7=AE=97=E6=A8=A1=E5=9D=97=20+=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=B2=BE=E5=BA=A6=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - incomeSwapTrade.js / swapTradeEdit.js 改为调用 SwapCalc.*(缩放/取整/全价推导) - 新增 fe-tests 守卫:4 个历史 bug 回归 + 8 场景对齐 C# FrontendCalcReference 金标准 + otcformat.js 精度配置守卫 - 新增 .git/hooks/pre-commit 自动运行前端守卫(失败阻断提交) --- YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml | 1 + YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml | 1 + YLErpWeb/fe-tests/_shim_run.js | 50 ++++++ YLErpWeb/fe-tests/otcformat.test.js | 50 ++++++ YLErpWeb/fe-tests/package.json | 12 ++ YLErpWeb/fe-tests/parity.test.js | 96 +++++++++++ YLErpWeb/fe-tests/swapCalc.test.js | 136 +++++++++++++++ .../Scripts/app/swaptrade/incomeSwapTrade.js | 6 +- .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 161 ++++++++++++++++++ .../Scripts/app/swaptrade/swapTradeEdit.js | 2 +- 10 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 YLErpWeb/fe-tests/_shim_run.js create mode 100644 YLErpWeb/fe-tests/otcformat.test.js create mode 100644 YLErpWeb/fe-tests/package.json create mode 100644 YLErpWeb/fe-tests/parity.test.js create mode 100644 YLErpWeb/fe-tests/swapCalc.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index 80e6ce7a..b02667f9 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -28,6 +28,7 @@ + }
diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 3e8660bc..b8c7d03f 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -96,6 +96,7 @@ + }
diff --git a/YLErpWeb/fe-tests/_shim_run.js b/YLErpWeb/fe-tests/_shim_run.js new file mode 100644 index 00000000..5f4cfbb9 --- /dev/null +++ b/YLErpWeb/fe-tests/_shim_run.js @@ -0,0 +1,50 @@ +/** + * _shim_run.js — 不依赖 jest 的轻量测试运行器(仅用于在沙箱内验证测试文件逻辑)。 + * 本机请用 jest:cd YLErpWeb/fe-tests && npm i && npm test + */ +let passed = 0; +let failed = 0; +const failures = []; + +function fmt(v) { + return typeof v === 'object' ? JSON.stringify(v) : String(v); +} + +function makeExpect(actual) { + return { + toBe(expected) { + if (Object.is(actual, expected)) { passed++; } + else { failed++; failures.push('toBe: expected ' + fmt(expected) + ' got ' + fmt(actual)); } + }, + toBeDefined() { + if (actual !== undefined && actual !== null) { passed++; } + else { failed++; failures.push('toBeDefined: got ' + fmt(actual)); } + }, + toBeLessThanOrEqual(n) { + if (actual <= n) { passed++; } + else { failed++; failures.push('toBeLessThanOrEqual: ' + fmt(actual) + ' <= ' + n + ' failed'); } + }, + toBeGreaterThanOrEqual(n) { + if (actual >= n) { passed++; } + else { failed++; failures.push('toBeGreaterThanOrEqual: ' + fmt(actual) + ' >= ' + n + ' failed'); } + }, + }; +} + +global.expect = (actual) => makeExpect(actual); +global.describe = (name, fn) => { fn(); }; +global.test = (name, fn) => { + try { fn(); } + catch (e) { failed++; failures.push(name + ': ' + e.message); } +}; +global.it = global.test; + +require('./swapCalc.test.js'); +require('./otcformat.test.js'); +require('./parity.test.js'); + +console.log('\n==== RUNNER (jest-shim) ===='); +console.log('PASS=' + passed + ' FAIL=' + failed); +failures.forEach((f) => console.log(' ✗ ' + f)); +console.log(failures.length ? 'RESULT: RED' : 'RESULT: GREEN'); +process.exit(failed > 0 ? 1 : 0); diff --git a/YLErpWeb/fe-tests/otcformat.test.js b/YLErpWeb/fe-tests/otcformat.test.js new file mode 100644 index 00000000..03604a40 --- /dev/null +++ b/YLErpWeb/fe-tests/otcformat.test.js @@ -0,0 +1,50 @@ +/** + * otcformat.test.js — 守卫运行时配置 otcformat.js 的精度回归 + * ============================================================================ + * 背景:088df270 中 otcformat.js 的 precision 被 Revert 误从 9 改回 2, + * 导致"期末全价只能录 2 位小数"。该 bug 已复发 2 次,纯前端/后端测试都碰不到。 + * 本测试直接解析 App_Data/Config/otcformat.js,断言关键字段 precision 不低于预期, + * 是 ROI 最高的单一守卫(零重构、1 个文件、无依赖)。 + * + * 运行:cd YLErpWeb/fe-tests && npm i && npm test + */ +const fs = require('fs'); +const path = require('path'); + +const cfgPath = path.resolve(__dirname, '../App_Data/Config/otcformat.js'); +const src = fs.readFileSync(cfgPath, 'utf8'); + +// otcformat.js 形如 `var main = main || {}; main.formatOptions = {...};` +// 在沙箱函数内求值并取出 main.formatOptions +const formatOptions = new Function(src + '\n;return main.formatOptions;')(); + +describe('otcformat.js 精度配置守卫 (088df270)', () => { + test('配置文件可被解析且含 trading 节点', () => { + expect(formatOptions).toBeDefined(); + expect(formatOptions.trading).toBeDefined(); + }); + + // 这些字段在 088df270 被误降到 2,必须保持 9(与当前全 precision=9 一致) + const mustBeNine = [ + 'umprice', 'umpriceP', 'umpricePR', + 'tradeSinglePrice', 'tradePrice', 'StockEqvNotional', 'notional', + 'premiumRate', 'premiumRateP', 'volatility', + 'marginRate', 'marginRateP', 'greek' + ]; + + mustBeNine.forEach(function (key) { + test('trading.' + key + '.precision === 9', () => { + expect(formatOptions.trading[key]).toBeDefined(); + expect(formatOptions.trading[key].precision).toBe(9); + }); + }); + + test('所有 trading 数值字段 precision 均 >= 9(防再次误降)', () => { + Object.keys(formatOptions.trading).forEach(function (key) { + const opt = formatOptions.trading[key]; + if (opt && typeof opt.precision === 'number') { + expect(opt.precision).toBeGreaterThanOrEqual(9); + } + }); + }); +}); diff --git a/YLErpWeb/fe-tests/package.json b/YLErpWeb/fe-tests/package.json new file mode 100644 index 00000000..63ecee9a --- /dev/null +++ b/YLErpWeb/fe-tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "zszq-trs-fe-tests", + "version": "1.0.0", + "private": true, + "description": "前端 JS 单元测试:守卫互换结算/平仓的小数点 bug(与 C# FrontendCalcReference 交叉校验)", + "scripts": { + "test": "jest" + }, + "devDependencies": { + "jest": "^29.7.0" + } +} diff --git a/YLErpWeb/fe-tests/parity.test.js b/YLErpWeb/fe-tests/parity.test.js new file mode 100644 index 00000000..a9ba5053 --- /dev/null +++ b/YLErpWeb/fe-tests/parity.test.js @@ -0,0 +1,96 @@ +/** + * parity.test.js — 生产代码 vs SwapCalc 等价性证明(替换前的"零风险闸门") + * ============================================================================ + * 方法:把 incomeSwapTrade.js / swapTradeEdit.js 里 4 个公式的【生产表达式逐字】 + * 抄成 PROD_* 函数,与 SwapCalc.* 在 FC 场景 + 随机 + 精度边界上对比。 + * 只有本文件 100% 绿灯,才允许把生产内联表达式替换为 SwapCalc.* 调用。 + * 本文件只读对比,不改动任何生产代码。 + */ +const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); + +// ---- 生产表达式逐字抄录(来源见注释行号) ---- +// incomeSwapTrade.js L76 +function PROD_getPriceScale(multiplier) { return multiplier == 100 ? 0.01 : 1; } +// incomeSwapTrade.js L87(仅此一处是初值推导,L156/L268 是别的逻辑,不在此对比) +function PROD_deriveTradingAmountAvg(initPosiGrossPrice, multiplier) { return initPosiGrossPrice * multiplier; } +// incomeSwapTrade.js L179:4 个加数均为 2 位小数,.toFixed(2) 与 round-to-2 等价 +function PROD_calcFloatPnlSum(markClosePnl, TradingFee, TradingFeePending, DividendIn) { + return (parseFloat(markClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2); +} +// swapTradeEdit.js L360:lodash _.round(x,2) === Math.round(x*100)/100 +function lodashRound(x, d) { var f = Math.pow(10, d); return Math.round(x * f) / f; } +function PROD_calcStockEqvNotional(price, national) { return lodashRound(price * national, 2); } + +// 随机 2 位小数金额(模拟真实环境:所有加数都已是 2 位小数) +function rand2() { return Math.round(Math.random() * 2000000) / 100; } // 0.00 ~ 20000.00 + +describe('parity: getPriceScale (incomeSwapTrade.js L76)', () => { + const cases = [100, 1, 1000, 0, 200, 10]; + cases.forEach((m) => { + test('multiplier=' + m, () => { + expect(PROD_getPriceScale(m)).toBe(SwapCalc.getPriceScale(m)); + }); + }); +}); + +describe('parity: deriveTradingAmountAvg (incomeSwapTrade.js L87)', () => { + // FC 场景的 (grossPrice, multiplier) + const cases = [ + [1.02, 100], [100, 1], [1.02, 100], [95.5, 100], [102.34, 100], [50, 1], + ]; + cases.forEach(([p, m]) => { + test('gross=' + p + ' mult=' + m, () => { + expect(PROD_deriveTradingAmountAvg(p, m)).toBe(SwapCalc.deriveTradingAmountAvg(p, m)); + }); + }); +}); + +describe('parity: calcStockEqvNotional (swapTradeEdit.js L360)', () => { + const cases = [ + [1.02, 10000], [100, 1000], [1.0235, 5000], [99.99, 100], [0.5, 200], + ]; + cases.forEach(([p, n]) => { + test('price=' + p + ' national=' + n, () => { + expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n)); + }); + }); + test('随机 200 组 2 位小数输入', () => { + for (let i = 0; i < 200; i++) { + const p = rand2(), n = Math.round(Math.random() * 100000); + expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n)); + } + }); +}); + +describe('parity: calcFloatPnlSum (incomeSwapTrade.js L179) — 真实 2 位小数输入', () => { + // 真实场景:MarkClosePnl 已在 L178 被 otcformat 取整为 2 位;费用/分红也 2 位 + const cases = [ + [30, 20, 0, 0], [100.456, 1, 0.5, 0], [-5000, 0, 0, 0], [300, 100, 50, 0], [450, 100, 50, 100], + [rand2(), rand2(), rand2(), rand2()], [rand2(), rand2(), rand2(), rand2()], + ]; + cases.forEach((c, idx) => { + test('case#' + idx, () => { + const prod = parseFloat(PROD_calcFloatPnlSum(c[0], c[1], c[2], c[3])); + const swap = SwapCalc.calcFloatPnlSum(c[0], c[1], c[2], c[3]); + expect(prod).toBe(swap); // 2 位小数输入下 .toFixed(2) === round-to-2 + }); + }); + test('随机 300 组 2 位小数输入(证明真实路径零差异)', () => { + for (let i = 0; i < 300; i++) { + const a = rand2(), b = rand2(), c = rand2(), d = rand2(); + const prod = parseFloat(PROD_calcFloatPnlSum(a, b, c, d)); + const swap = SwapCalc.calcFloatPnlSum(a, b, c, d); + expect(prod).toBe(swap); + } + }); +}); + +// 最坏输入验证:即便喂入未取整的原始值(如 1.005),toFixed 与 round-half-away 在浮点现实下 +// 同样得到 1.00,证明不存在舍入接缝。生产环境 4 个加数恒为 2 位小数,更不可能分歧。 +describe('parity: 最坏输入也无舍入接缝', () => { + test('原始 1.005 输入下两者仍一致', () => { + const prod = parseFloat(PROD_calcFloatPnlSum(1.005, 0, 0, 0)); // "1.00" + const swap = SwapCalc.calcFloatPnlSum(1.005, 0, 0, 0); // 1.00 + expect(prod).toBe(swap); + }); +}); diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js new file mode 100644 index 00000000..7d3dca9b --- /dev/null +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -0,0 +1,136 @@ +/** + * swapCalc.test.js — 前端计算逻辑单元测试 + * ============================================================================ + * 双重目的: + * 1) 回归守卫:锁定 4 个曾出 bug 的纯函数(dcf649f2 / 20ea93d8 / 3c5f25a5 / f873239a) + * 2) 交叉校验:用 8 个场景(FC_001~FC_008)对齐 C# FrontendCalcCharacterizationTest 金标准, + * 一旦 JS 公式与后端 FrontendCalcReference 分叉,测试即红。 + * + * 运行:cd YLErpWeb/fe-tests && npm i && npm test + */ +const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); + +const TOL = 1e-6; +function expectClose(actual, expected, msg) { + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || ''); +} + +describe('回归守卫:曾出 bug 的纯函数', () => { + // 20ea93d8 / dcf649f2:必须用全价(PosiGrossPrice) 且债券 ×100 + test('deriveTradingAmountAvg 债券用全价并 ×100 (守卫 20ea93d8/dcf649f2)', () => { + expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 100), 102, '债券: 1.02×100=102(界面百分比态)'); + expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 1), 1.02, '非债券: 不缩放'); + // 若误用净价(PosiNetPrice) 会偏离,这里锁定"全价"语义 + expect(SwapCalc.deriveTradingAmountAvg(1.02, 100)).toBe(102); + }); + + // 3c5f25a5:FloatPnlSum 必须保留 2 位小数 + test('calcFloatPnlSum 保留 2 位 (守卫 3c5f25a5)', () => { + expectClose(SwapCalc.calcFloatPnlSum(100.456, 1, 0.5, 0), 101.96, '100.456+1+0.5=101.956→101.96'); + expectClose(SwapCalc.calcFloatPnlSum(30, 20, 0, 0), 50, '30+20=50'); + expect(SwapCalc.calcFloatPnlSum(30, 20, 0, 0)).toBe(50); + }); + + // f873239a:名义本金 round 到 2 位 + test('calcStockEqvNotional round 2 位 (守卫 f873239a)', () => { + expectClose(SwapCalc.calcStockEqvNotional(1.02, 100 * 100), 10200, '1.02×10000=10200'); + expectClose(SwapCalc.calcStockEqvNotional(10.005, 100), 1000.5, '10.005×100=1000.50'); + }); + + test('getPriceScale 债券=0.01 非债券=1', () => { + expect(SwapCalc.getPriceScale(100)).toBe(0.01); + expect(SwapCalc.getPriceScale(1)).toBe(1); + }); +}); + +describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => { + // FC_001 平仓-债券多头-默认 + test('FC_001 平仓 债券多头 默认', () => { + const r = SwapCalc.calcUnwind({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, + closeQty: 1000, payDirection: 1, positionType: 1, + tradingFee: '20', tradingFeePending: '0', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, 30, 'MarkClosePnl'); + expectClose(r.FloatPnlSum, 50, 'FloatPnlSum'); + expectClose(r.SwapRealizedPnL, 50, 'SwapRealizedPnL'); + }); + + // FC_002 改标的价格 105→110 + test('FC_002 平仓 改标的价格', () => { + const r = SwapCalc.calcUnwind({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110, + closeQty: 1000, payDirection: 1, positionType: 1, + tradingFee: '20', tradingFeePending: '0', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, 80, 'MarkClosePnl'); + expectClose(r.FloatPnlSum, 100, 'FloatPnlSum'); + }); + + // FC_003 改平仓数量 1000→500 + test('FC_003 平仓 改平仓数量', () => { + const r = SwapCalc.calcUnwind({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, + closeQty: 500, payDirection: 1, positionType: 1, + tradingFee: '20', tradingFeePending: '10', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, 15, 'MarkClosePnl'); + expectClose(r.FloatPnlSum, 45, 'FloatPnlSum'); + }); + + // FC_004 改利息金额 +100 + test('FC_004 平仓 改利息金额', () => { + const r = SwapCalc.calcUnwind({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, + closeQty: 1000, payDirection: 1, positionType: 1, + tradingFee: '20', tradingFeePending: '0', dividendIn: '0', + interestLegs: [{ interestClosePnL: 100 }] + }); + expectClose(r.MarkClosePnl, 30, 'MarkClosePnl 不受利息影响'); + expectClose(r.SwapRealizedPnL, 150, '含利息 SwapRealizedPnL'); + }); + + // FC_005 非债券空头 方向因子 + test('FC_005 平仓 非债券空头 方向因子', () => { + const r = SwapCalc.calcUnwind({ + multiplier: 1, posiGrossPrice: 100, tradingAmountAvg: 105, + closeQty: 1000, payDirection: 1, positionType: 2, + tradingFee: '0', tradingFeePending: '0', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, -5000, '空头价格涨=亏损'); + }); + + // FC_006 结息 债券多头 全量 + test('FC_006 结息 债券多头 全量', () => { + const r = SwapCalc.calcIncome({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, + closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + tradingFee: '0', tradingFeePending: '0', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, 300, 'income MarkClosePnl'); + expectClose(r.SwapRealizedPnL, 300, 'income SwapRealizedPnL'); + }); + + // FC_007 结息 改标的价格 105→110 + test('FC_007 结息 改标的价格', () => { + const r = SwapCalc.calcIncome({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110, + closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + tradingFee: '0', tradingFeePending: '0', dividendIn: '0' + }); + expectClose(r.MarkClosePnl, 800, '改价格后 income MarkClosePnl'); + }); + + // FC_008 结息 含利息腿+预付金腿 + test('FC_008 结息 含利息腿与预付金腿 总额', () => { + const r = SwapCalc.calcIncome({ + multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, + closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + tradingFee: '0', tradingFeePending: '0', dividendIn: '0', + interestLegs: [{ interestClosePnL: 100 }], + marginLegs: [{ interestClosePnL: 50 }] + }); + expectClose(r.SwapRealizedPnL, 450, '含利息+预付金 SwapRealizedPnL'); + expectClose(r.SwapMarginRebatePnl, 50, 'SwapMarginRebatePnl'); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index 09bcf96b..a9681684 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -73,7 +73,7 @@ const vue = new Vue({ return false; }, getPriceScale() { - return this.multiplier == 100 ? 0.01 : 1; + return SwapCalc.getPriceScale(this.multiplier); }, initDeal() { var positions = model.FlowEvents.filter((item) => { @@ -84,7 +84,7 @@ const vue = new Vue({ this.initPosiGrossPrice = this.floatPosition.PosiGrossPrice; // 互换标的价格固定为期初净价,与平仓不同不需要用户填写 // 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致 - this.floatPosition.TradingAmountAvg = this.initPosiGrossPrice * this.multiplier; + this.floatPosition.TradingAmountAvg = SwapCalc.deriveTradingAmountAvg(this.initPosiGrossPrice, this.multiplier); this.interestList = model.FlowEvents.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; }); @@ -176,7 +176,7 @@ const vue = new Vue({ //thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 - thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2); + thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2); thisObj.calcCloseAmount(); }, //calcClosePnL() {//计算浮动端平仓盈亏 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js new file mode 100644 index 00000000..35670d9b --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -0,0 +1,161 @@ +/** + * swapCalc.js — 互换结算/平仓纯计算函数(与 C# FrontendCalcReference 对齐) + * ============================================================================ + * 设计要点: + * - 无 Vue / otcformat / jQuery / lodash 依赖,全部为纯函数,便于 jest 直接 import。 + * - 浏览器:挂到 window.SwapCalc(需在 incomeSwapTrade.js / swapTradeEdit.js 之前加载)。 + * - Node: module.exports(UMD 包装),供 fe-tests/*.test.js 使用。 + * - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致,是前后端同一份金标准。 + * + * 守卫的 bug(见 git 历史): + * - 20ea93d8 / dcf649f2:deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100 + * - 3c5f25a5:calcFloatPnlSum 必须 .toFixed(2)(保留 2 位小数) + * - f873239a:calcStockEqvNotional 必须 round 到 2 位 + * ============================================================================ + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + } else { + root.SwapCalc = factory(); + } +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + // 四舍五入(远离零),对齐 C# MidpointRounding.AwayFromZero + function roundHalfAwayFromZero(value, digits) { + var f = Math.pow(10, digits); + var n = Number(value) * f; + var sign = n < 0 ? -1 : 1; + var r = Math.round(Math.abs(n)) * sign; + var result = r / f; + return result === 0 ? 0 : result; // 消除 -0 + } + + // 价格缩放因子:债券(multiplier=100)界面为百分比态,计算用相对价需 ÷100 + function getPriceScale(multiplier) { + return multiplier === 100 ? 0.01 : 1; + } + + // 期末全价(界面态) = 期初全价(相对价) × multiplier + // 必须用 PosiGrossPrice(全价),非 PosiNetPrice(净价);债券 ×100 转界面百分比态 + function deriveTradingAmountAvg(posiGrossPrice, multiplier) { + return posiGrossPrice * multiplier; + } + + // 金额四舍五入到指定小数位(避免 0.1+0.2 类浮点误差) + function roundMoney(value, digits) { + return roundHalfAwayFromZero(value, digits); + } + + // 浮动盈亏合计 = (平仓盈亏 + 交易费用 + 待结算费用 + 分红).toFixed(2) + function calcFloatPnlSum(markClosePnl, tradingFee, tradingFeePending, dividendIn) { + var sum = (+markClosePnl) + (+tradingFee) + (+tradingFeePending) + (+dividendIn); + return roundHalfAwayFromZero(sum, 2); + } + + // 名义本金 = 期初全价 × 因子,保留 2 位(EQD-6090) + // factor 在前端 = 数量 × 乘数(national) + function calcStockEqvNotional(posiGrossPrice, factor) { + return roundHalfAwayFromZero(posiGrossPrice * factor, 2); + } + + // 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale − 期初全价) × floatRatio × longRatio + // 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2) + // 干净输入下等价于直接 round(.., 2) + function calcMarkClosePnl(closeQty, tradingAmountAvg, scale, entryPrice, floatRatio, longRatio) { + var product = closeQty * (tradingAmountAvg * scale - entryPrice) * floatRatio * longRatio; + var step = Math.round(product * 10000) / 10000; // 对齐 C# Math.Round(.. * 10000) / 10000 + return roundHalfAwayFromZero(step, 2); + } + + // ---- 组合函数:对齐 C# CalcUnwind / CalcIncome,作为前端与后端金标准的交叉校验 ---- + + function parseOrZero(s) { + return (s === undefined || s === null || s === '') ? 0 : Number(s); + } + + function sumLegs(legs) { + return (legs || []).reduce(function (acc, l) { return acc + parseOrZero(l.interestClosePnL); }, 0); + } + + // 平仓页(unwind)盈亏汇总 — 对齐 FrontendCalcReference.CalcUnwind + function calcUnwind(input) { + var entryPrice = input.posiGrossPrice; + var scale = input.multiplier === 100 ? 0.01 : 1; + var floatRatio = input.payDirection === 1 ? 1 : -1; + var longRatio = input.positionType === 1 ? 1 : -1; + + var tradingFee = parseOrZero(input.tradingFee); + var tradingFeePending = parseOrZero(input.tradingFeePending); + var dividendIn = parseOrZero(input.dividendIn); + + var markClosePnl = calcMarkClosePnl( + input.closeQty, input.tradingAmountAvg, scale, entryPrice, floatRatio, longRatio); + markClosePnl = roundHalfAwayFromZero(markClosePnl, 2); + + var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2); + + var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapMarginRebatePnl = sumLegs(input.marginLegs); + + var ratio = input.positionType === 1 ? 1 : -1; + var tradingAmountFeeAvg = input.closeQty === 0 ? 0 + : input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * ratio; + + return { + MarkClosePnl: roundHalfAwayFromZero(markClosePnl, 2), + FloatPnlSum: floatPnlSum, + SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2), + SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2), + SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2), + TradingAmountFeeAvg: tradingAmountFeeAvg + }; + } + + // 结息页(income)盈亏汇总 — 对齐 FrontendCalcReference.CalcIncome + function calcIncome(input) { + var entryPrice = input.posiGrossPrice; + var scale = input.multiplier === 100 ? 0.01 : 1; + var floatRatio = input.payDirection === 1 ? 1 : -1; + + var tradingFee = parseOrZero(input.tradingFee); + var tradingFeePending = parseOrZero(input.tradingFeePending); + var dividendIn = parseOrZero(input.dividendIn); + + var markClosePnl = roundHalfAwayFromZero( + input.closeNotionalValue * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2); + + var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2); + + var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapMarginRebatePnl = sumLegs(input.marginLegs); + + var tradingAmountFeeAvg = input.closeQty > 0 + ? input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * floatRatio + : input.tradingAmountAvg * scale; + + return { + MarkClosePnl: markClosePnl, + FloatPnlSum: floatPnlSum, + SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2), + SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2), + SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2), + TradingAmountFeeAvg: tradingAmountFeeAvg + }; + } + + return { + roundHalfAwayFromZero: roundHalfAwayFromZero, + getPriceScale: getPriceScale, + deriveTradingAmountAvg: deriveTradingAmountAvg, + roundMoney: roundMoney, + calcFloatPnlSum: calcFloatPnlSum, + calcStockEqvNotional: calcStockEqvNotional, + calcMarkClosePnl: calcMarkClosePnl, + calcUnwind: calcUnwind, + calcIncome: calcIncome + }; +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 0b3f630b..e19f89d1 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -357,7 +357,7 @@ const vue = new Vue({ this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem); } var national = payItem.PosiQuantity * payItem.ContractSize; - var stockEqvNotional = _.round(payItem.PosiGrossPrice * national, 2);//名义本金=期初价格*数量*乘数 + var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional; } From b3c2fdfe38236c5daef611b987b21260fa3659d5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 7 Jul 2026 13:26:32 +0800 Subject: [PATCH 11/88] =?UTF-8?q?test(fe):=20=E8=A1=A5=E5=85=85=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=9E=B6=E6=9E=84=E9=97=B8?= =?UTF-8?q?=E9=97=A8=EF=BC=8C=E9=98=B2=E6=96=B0=E5=A2=9E=E5=86=85=E8=81=94?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生产接线处补注释,追溯到具体历史 bug (dcf649f2/20ea93d8/3c5f25a5/f873239a) - swapCalc.js 注明哪些函数已接入生产、哪些是纯交叉校验规格 - 新增 guard_arch.js 架构闸门:扫描提交新增行,拦截 Vue 组件内联金额计算 - pre-commit hook 串行运行 单元测试 + 架构闸门 --- YLErpWeb/fe-tests/guard_arch.js | 122 ++++++++++++++++++ .../Scripts/app/swaptrade/incomeSwapTrade.js | 6 + .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 12 +- .../Scripts/app/swaptrade/swapTradeEdit.js | 1 + 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 YLErpWeb/fe-tests/guard_arch.js diff --git a/YLErpWeb/fe-tests/guard_arch.js b/YLErpWeb/fe-tests/guard_arch.js new file mode 100644 index 00000000..d480b6ab --- /dev/null +++ b/YLErpWeb/fe-tests/guard_arch.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * guard_arch.js — 前端架构闸门(零依赖,纯 Node) + * ============================================================================ + * 目的:防止在 Vue 组件方法里【新增】手写「金额 / 精度」计算(toFixed / _.round / + * Math.round / × multiplier 缩放等),避免又长出一份「不可测的内联公式」。 + * + * 规则:仅扫描【本次提交改动中新增/修改的行】(git diff 的 + 行),且位于 + * wwwroot/Scripts/app/ 并定义 new Vue(...) 的文件。对其中每一行「金额算术」, + * 若该行没有路由到某个 *Calc 模块(含 SwapCalc.),则判为违规 → 退出码 1。 + * + * 设计要点: + * - 只查「新增/修改的行」,不查历史存量。这样不会阻断对存量文件(如仍含内联计算的 + * unwindSwapTrade.js)的正常改动,只拦「新写的内联金额公式」。 + * - 已外置计算逻辑的组件(incomeSwapTrade.js / swapTradeEdit.js)通过调用 SwapCalc.* + * 保持合规;新代码必须把金额计算放进 *Calc 模块(参考 swapCalc.js)。 + * - 注释行会被剥离后再判断,避免注释里的样例文字误报。 + * - 无需 jest / npm install,和 _shim_run.js 同属零依赖守卫。 + * + * 用法:node guard_arch.js (一般在 pre-commit / CI 中自动调用) + * ============================================================================ + */ +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// fe-tests -> YLErpWeb -> zszq-trs(仓库根) +const ROOT = path.resolve(__dirname, '..', '..'); + +// 金额 / 精度算术模式(命中即怀疑) +const MONEY_PATTERNS = [ + /\.toFixed\s*\(/, // 显示/入库精度 + /_\.round\s*\(/, // lodash 四舍五入 + /Math\.round\s*\(/, // 原生四舍五入 + /\*\s*this\.multiplier/, // 价格 × 乘数缩放(dcf649f2 / 20ea93d8 类) + /\*\s*thisObj\.multiplier/, +]; + +// 合规标记:行内引用了某个 *Calc 模块(如 SwapCalc.),视为已外置 +const COMPLIANT = /Calc\./; + +function tryCmd(cmd) { + try { + return execSync(cmd, { cwd: ROOT }).toString().trim(); + } catch (e) { + return ''; + } +} + +function changedAppVueFiles() { + const listCmd = 'git diff --cached --name-only --diff-filter=ACM -- "*.js"'; + let out = tryCmd(listCmd); + if (!out) out = tryCmd('git diff --name-only --diff-filter=ACM -- "*.js"'); + if (!out) return []; + return out.split('\n').filter((f) => { + if (!/wwwroot[\\/]Scripts[\\/]app[\\/]/.test(f)) return false; + const full = path.join(ROOT, f); + if (!fs.existsSync(full)) return false; + try { + return /new\s+Vue\s*\(/.test(fs.readFileSync(full, 'utf8')); + } catch (e) { + return false; + } + }); +} + +// 取文件在本次提交中【新增/修改】的行(git diff 的 + 行,排除 +++ 文件头) +function addedLines(file) { + const quoted = JSON.stringify(file); + let out = tryCmd(`git diff --cached -U0 -- ${quoted}`); + if (!out) out = tryCmd(`git diff -U0 -- ${quoted}`); + if (!out) return []; + return out.split('\n') + .filter((l) => l.startsWith('+') && !l.startsWith('+++')) + .map((l) => l.slice(1)); +} + +function stripComment(line) { + // 去掉 /* */ 块注释与 // 行内注释,避免注释里的样例文字误报 + return line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '').trim(); +} + +function main() { + const files = changedAppVueFiles(); + if (files.length) { + console.log(`[guard_arch] 扫描 ${files.length} 个改动的 Vue 组件文件的新增/修改行`); + } + + let violations = 0; + for (const file of files) { + const added = addedLines(file); + const found = []; + added.forEach((rawLine) => { + const code = stripComment(rawLine); + if (!code) return; + const hasMoney = MONEY_PATTERNS.some((p) => p.test(code)); + if (hasMoney && !COMPLIANT.test(code)) { + found.push(` + ${rawLine.trim()}`); + } + }); + + if (found.length) { + violations += found.length; + console.log(`[guard_arch] 违规 ${file} 发现【新增】内联金额计算(应路由到 *Calc 模块):`); + found.forEach((f) => console.log(f)); + } + } + + if (violations > 0) { + console.log( + `\n[guard_arch] 发现 ${violations} 处【新增】内联金额计算, 提交被阻断。` + + `请把金额/精度计算抽到 *Calc 模块(参考 swapCalc.js)。` + ); + process.exit(1); + } + console.log('[guard_arch] OK: 改动的 Vue 组件未新增内联金额计算。'); + process.exit(0); +} + +main(); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index a9681684..568f2ecd 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -72,6 +72,8 @@ const vue = new Vue({ $(this.$refs.incomeValueDatePicker.$el).val(MaxIncomeValueDate); return false; }, + // 守卫: 价格缩放因子(债券 multiplier=100 时界面为百分比态, 计算用相对价需 ÷100) + // 计算已外置到 swapCalc.getPriceScale; 改动需同步 swapCalc.test.js getPriceScale() { return SwapCalc.getPriceScale(this.multiplier); }, @@ -84,6 +86,8 @@ const vue = new Vue({ this.initPosiGrossPrice = this.floatPosition.PosiGrossPrice; // 互换标的价格固定为期初净价,与平仓不同不需要用户填写 // 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致 + // 守卫: 期末全价必须用 PosiGrossPrice(全价) 且债券 ×100 转界面态 + // 对应历史 bug dcf649f2(错用净价) / 20ea93d8(×100 缩放丢失); 外置到 swapCalc.deriveTradingAmountAvg this.floatPosition.TradingAmountAvg = SwapCalc.deriveTradingAmountAvg(this.initPosiGrossPrice, this.multiplier); this.interestList = model.FlowEvents.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; @@ -176,6 +180,8 @@ const vue = new Vue({ //thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 + // 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留) + // 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游 thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2); thisObj.calcCloseAmount(); }, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 35670d9b..d9f6edbd 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -7,6 +7,11 @@ * - Node: module.exports(UMD 包装),供 fe-tests/*.test.js 使用。 * - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致,是前后端同一份金标准。 * + * 生产接线状态(tested == used):incomeSwapTrade.js / swapTradeEdit.js 已调用 + * getPriceScale / deriveTradingAmountAvg / calcFloatPnlSum / calcStockEqvNotional + * 这 4 个叶子函数(对应真实出过的 4 个 bug:20ea93d8 / dcf649f2 / 3c5f25a5 / f873239a)。 + * calcUnwind / calcIncome 仅用于 swapCalc.test.js 的前后端金标准交叉校验,未接入生产代码。 + * * 守卫的 bug(见 git 历史): * - 20ea93d8 / dcf649f2:deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100 * - 3c5f25a5:calcFloatPnlSum 必须 .toFixed(2)(保留 2 位小数) @@ -69,7 +74,12 @@ return roundHalfAwayFromZero(step, 2); } - // ---- 组合函数:对齐 C# CalcUnwind / CalcIncome,作为前端与后端金标准的交叉校验 ---- + // ---- 组合函数:对齐 C# FrontendCalcReference.CalcUnwind / CalcIncome ---- + // 用途:作为「前端 JS 完整盈亏聚合公式」与「后端 C# 金标准」的交叉校验 + // (见 swapCalc.test.js 的 FC_001~FC_008 八个冻结场景)。 + // 注意:以下 calcUnwind / calcIncome **未接入生产代码**——生产 Vue 组件只调用上方 + // 4 个叶子函数。它们是冻结完整聚合逻辑的参考规格;若要让生产聚合逻辑也被自动守卫, + // 需把 incomeSwapTrade.js / swapTradeEdit.js / unwindSwapTrade.js 的聚合计算也改调它们。 function parseOrZero(s) { return (s === undefined || s === null || s === '') ? 0 : Number(s); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index e19f89d1..dc726ce2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -357,6 +357,7 @@ const vue = new Vue({ this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem); } var national = payItem.PosiQuantity * payItem.ContractSize; + // 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional; 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 12/88] =?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 @@ -