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;
+ }
+
///
/// 获取互换标的
///