diff --git a/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs b/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs new file mode 100644 index 00000000..f53b268a --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodCheckSettlePriceClientScopeTest.cs @@ -0,0 +1,116 @@ +using YLErp.Modules.EodModule.SettlementModule; + +namespace YLErp.Modules.EodModule +{ + /// + /// 验证 EodCheckSettlePrice 的持仓分支按 ClientIds 收敛: + /// 给定收盘客户时,不应再把“仅属于其他客户”的上一交易日持仓标的纳入结算价缺失检查。 + /// + /// 采用确定性夹具:插入两条合成持仓(客户A持标的A、客户B持标的B),直接调用抽出的 + /// static 查询方法断言过滤语义,finally 中清理,避免依赖测试库现有数据形状。 + /// 若 underlying_manager 无足够的对冲类型标的,则 Assert.Inconclusive 跳过。 + /// + [TestClass] + public class EodCheckSettlePriceClientScopeTest : UnitTestBase + { + [TestMethod] + public void PositionUnderlyingQuery_ExcludesOtherClients_WhenClientIdsGiven() + { + using var db = DbContextFactory.GetYLDbContext(); + + // 选两个存在的、非期货的标的(避开 GetFutureTypes,保证通过方法内部的期货到期过滤); + // 持仓 TradeType 固定为"股票"(属于 TradeTypesForHedge),才能进入结算价检查。 + var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); + var underlyings = db.underlying_manager + .Where(u => u.UnderlyingCode != null && !futureTypes.Contains(u.UnderlyingInstrumentType)) + .Take(5) + .ToList(); + if (underlyings.Count < 2) + { + Assert.Inconclusive("underlying_manager 无足够的非期货标的,跳过"); + return; + } + var uA = underlyings[0]; + var uB = underlyings[1]; + + // 复用一条现有持仓的 BookId/TradeId,确保外键合法(若存在) + var sample = db.eod_trade_position.FirstOrDefault(p => p.BookId != 0); + int bookId = sample?.BookId ?? 1; + int tradeId = sample?.TradeId ?? 0; + + // 合成日期与客户,避免与测试库真实数据冲突 + var preSettleDate = new DateTime(2026, 5, 1); + var settleDate = new DateTime(2026, 5, 2); + int clientA = 900001; + int clientB = 900002; + + var rows = new List + { + new eod_trade_position + { + ValueDate = preSettleDate, + ClientId = clientA, + UnderlyingCode = uA.UnderlyingCode, + UnderlyingId = uA.id, + TradeType = "股票", + BookId = bookId, + TradeId = tradeId, + Amount = 1, + HedgeUniqueCode = "UT_CLIENTSCOPE_A" + }, + new eod_trade_position + { + ValueDate = preSettleDate, + ClientId = clientB, + UnderlyingCode = uB.UnderlyingCode, + UnderlyingId = uB.id, + TradeType = "股票", + BookId = bookId, + TradeId = tradeId, + Amount = 1, + HedgeUniqueCode = "UT_CLIENTSCOPE_B" + } + }; + + foreach (var r in rows) + { + r.OptId = 0; + r.OptName = "UT_CLIENTSCOPE"; + r.OptDate = DateTime.Now; + } + + try + { + db.eod_trade_position.AddRange(rows); + db.SaveChanges(); + + var fullSet = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var filteredA = EodCheckSettlePrice.QueryPositionUnderlyingCodes(db, preSettleDate, settleDate, new List { clientA }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.IsTrue(fullSet.Contains(uA.UnderlyingCode), "全客户结果应包含客户A的标的"); + Assert.IsTrue(fullSet.Contains(uB.UnderlyingCode), "全客户结果应包含客户B的标的"); + Assert.IsTrue(filteredA.Contains(uA.UnderlyingCode), "按客户A收敛后仍应包含客户A的标的"); + // 关键断言:修复点——按客户A收敛后不应再包含“仅属客户B”的标的 + Assert.IsFalse(filteredA.Contains(uB.UnderlyingCode), + "修复验证失败:按客户A收敛后仍包含仅属客户B的持仓标的(ClientId 过滤未生效)"); + } + finally + { + // 清理合成数据,使测试库状态不变 + foreach (var r in rows) + { + var exist = db.eod_trade_position.FirstOrDefault(x => + x.ValueDate == preSettleDate && x.ClientId == r.ClientId && + x.UnderlyingCode == r.UnderlyingCode && x.HedgeUniqueCode == r.HedgeUniqueCode); + if (exist != null) + { + db.eod_trade_position.Remove(exist); + } + } + db.SaveChanges(); + } + } + } +} diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs index 87af919a..45539739 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodCheckSettlePrice.cs @@ -1,5 +1,5 @@ -using System.Linq; -using System.Linq.Dynamic.Core; +using YLErp.BLL; +using YLErp.Configuration.Enums; using YLErp.Modules.DataProviderModule; namespace YLErp.Modules.EodModule.SettlementModule @@ -7,7 +7,7 @@ namespace YLErp.Modules.EodModule.SettlementModule /// /// 检查当日结算价,是否全部进系统。 /// - class EodCheckSettlePrice : EodSettleServiceBaseV2 + public class EodCheckSettlePrice : EodSettleServiceBaseV2 { public const string Step = "检查标的结算价格缺失"; @@ -58,7 +58,7 @@ namespace YLErp.Modules.EodModule.SettlementModule allQuery = DbContext.trade.Where(tradePredicate).Select(n => n.UnderlyingCode).Distinct(); - if (PS.Config.ErpElement.ForwardTradePriceModel == Configuration.Enums.ForwardTradePriceModel.STANDARD) + if (PS.Config.ErpElement.ForwardTradePriceModel == ForwardTradePriceModel.STANDARD) { allQuery = allQuery.Union(DbContext.trade.Where(tradePredicate).Where(x => x.BasisUnderlyingCode != null && x.BasisUnderlyingCode != "").Select(n => n.BasisUnderlyingCode).Distinct()); } @@ -88,14 +88,8 @@ namespace YLErp.Modules.EodModule.SettlementModule allQuery = allQuery == null ? exTradeQuery.Distinct() : allQuery.Union(exTradeQuery.Distinct()); var preSettleDate = _context.PreSettleDate; - //最后一个交易日持仓信息 - var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); - var positionQuery = from t in DbContext.eod_trade_position - join um in DbContext.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode - where t.ValueDate == preSettleDate && t.Amount != 0 - && ConsTrade.TradeTypesForHedge.Contains(t.TradeType) - && (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate) - select t.UnderlyingCode; + // 持仓分支传入 clienIds:使上一交易日持仓标的按当前收盘客户作用域收敛(防御点详见 QueryPositionUnderlyingCodes)。 + var positionQuery = QueryPositionUnderlyingCodes(DbContext, preSettleDate, settleDate, clienIds); allQuery = allQuery.Union(positionQuery.Distinct()); } @@ -130,6 +124,41 @@ namespace YLErp.Modules.EodModule.SettlementModule _context.RaiseError(Step, "标的代码:" + codes); } } + + /// + /// 取上一交易日持仓中需结算价的标的(持仓分支)。 + /// 抽成 static 以便单测直接覆盖 ClientId 收敛语义。 + /// + /// ── 防御行为(EQD-6967)───────────────────────────────────────────── + /// 收盘缺失价检查在“按客户作用域结算(ClientIds 非 null)”时,本应只校验 + /// 当前收盘客户自己的标的,而不应把“其他客户”或“系统级(ClientId=0)”的 + /// 上一交易日持仓标的误报为缺失价。历史上持仓分支完全未引用 ClientId, + /// 导致客户 A 收盘时被其他客户/系统级持仓的标的噪声干扰(见缺陷现象)。 + /// + /// 本方法通过 clienIds 过滤做收敛,与 OTC 分支、客户产品分支已有的 + /// ClientId 过滤语义保持一致: + /// · clienIds == null → 系统级全量结算,短路为 true(不收窄,零回归); + /// · clienIds != null → 仅返回属于指定客户的持仓标的(Contains(t.ClientId)), + /// 过滤掉其他客户及系统级(ClientId=0)持仓噪声。 + /// 注意:场外交易结算模式(IsSettleExchangeTrades=false)下持仓分支整块跳过, + /// 此时本方法不会被调用,属操作层面的规避而非修复。 + /// ─────────────────────────────────────────────────────────────────── + /// + public static IQueryable QueryPositionUnderlyingCodes(YLContext db, DateTime preSettleDate, DateTime settleDate, IEnumerable clienIds) + { + var futureTypes = ConsGlobal.InstrumentType.GetFutureTypes(); + return from t in db.eod_trade_position + join um in db.underlying_manager on t.UnderlyingCode equals um.UnderlyingCode + where t.ValueDate == preSettleDate && t.Amount != 0 + && ConsTrade.TradeTypesForHedge.Contains(t.TradeType) + && (!futureTypes.Contains(um.UnderlyingInstrumentType) || um.MaturityDate >= settleDate) + // 【防御点·EQD-6967】按客户作用域收敛持仓标的: + // clienIds==null → 系统级全量结算,不收窄(零回归); + // clienIds!=null → 仅保留指定客户持仓,过滤掉其他客户/系统级(ClientId=0)持仓噪声。 + && (clienIds == null || clienIds.Contains(t.ClientId)) + select t.UnderlyingCode; + } + /// /// 获取互换标的 ///