From 3d631e0a07cf53d29ad8d29087e0cec823e799ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Fri, 28 Aug 2026 16:20:54 +0800 Subject: [PATCH] =?UTF-8?q?#EQD-7247=20=E5=AE=A2=E6=88=B7=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=EF=BC=9A=E7=BB=B4=E6=8C=81=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E4=B8=8D=E6=AD=A3=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...MarginTemplateV2BookScopePreferenceTest.cs | 95 +++++++++++++++++++ .../MarginTemplateV2RateHelperFallbackTest.cs | 53 ++++++++++- .../MarginTemplateV2RateHelper.cs | 68 +++++++++---- 3 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 UnitTestProject/Modules/CalcModules/MarginTemplateV2BookScopePreferenceTest.cs diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2BookScopePreferenceTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2BookScopePreferenceTest.cs new file mode 100644 index 00000000..bd05ae19 --- /dev/null +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2BookScopePreferenceTest.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using YLErp.DBModels; +using YLErp.Modules.MarginModule; + +namespace YLErp.Modules.CalcModules +{ + /// + /// 模板同层多候选取舍纯函数测试(MarginTemplateV2RateHelper.PickByBookScopePreference,2026-08-28 裁定): + /// 簿记条件满足者优先,其次生效日最新。不连库。 + /// + [TestClass] + public class MarginTemplateV2BookScopePreferenceTest + { + private static readonly DateTime Newer = new DateTime(2026, 8, 1); + private static readonly DateTime Older = new DateTime(2026, 1, 1); + + private static margin_template_v2 Tpl(int id, DateTime valueDate, string bookIds = null) + { + return new margin_template_v2 { id = id, ValueDate = valueDate, BookIds = bookIds, IsValid = true }; + } + + [TestMethod] + public void BP_01_双默认同日_限定簿记匹配_压过id更新的通配() + { + //复刻 dev 274/275 场景:同生效日按 id 降序,通配模板排前、限定簿记(45)排后;交易簿记=45 + var candidates = new List { Tpl(275, Older), Tpl(274, Older, "45") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out var byBook); + Assert.AreEqual(274, picked.id, "簿记条件满足的限定模板应压过同日 id 更新的通配模板"); + Assert.IsTrue(byBook, "命中由簿记匹配优先产生时应置 preferredByBookScope"); + } + + [TestMethod] + public void BP_02_簿记不匹配_通配胜出() + { + //交易簿记=45 不在限定范围(38)内:限定模板不适用,按序取通配(2573 客需债券簿记场景) + var candidates = new List { Tpl(275, Older), Tpl(274, Older, "38") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out var byBook); + Assert.AreEqual(275, picked.id); + Assert.IsFalse(byBook); + } + + [TestMethod] + public void BP_03_无限定候选_取生效日最新() + { + var candidates = new List { Tpl(2, Newer), Tpl(1, Older) }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out var byBook); + Assert.AreEqual(2, picked.id, "无簿记限定候选时维持原口径:生效日最新胜出"); + Assert.IsFalse(byBook); + } + + [TestMethod] + public void BP_04_多限定都匹配_取生效日最新() + { + var candidates = new List { Tpl(2, Newer, "45,46"), Tpl(1, Older, "45") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out var byBook); + Assert.AreEqual(2, picked.id, "多个簿记条件满足的候选之间仍按生效日最新取第一个"); + Assert.IsFalse(byBook, "第一个适用候选即簿记匹配时不算'优先压过'"); + } + + [TestMethod] + public void BP_05_限定匹配_压过生效日更新的通配() + { + var candidates = new List { Tpl(2, Newer), Tpl(1, Older, "45") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out var byBook); + Assert.AreEqual(1, picked.id, "簿记条件满足优先于生效日新旧"); + Assert.IsTrue(byBook); + } + + [TestMethod] + public void BP_06_全部不适用_返回null() + { + var candidates = new List { Tpl(1, Newer, "38") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, 45, out _); + Assert.IsNull(picked, "候选均不适用当前簿记账户时应返回 null(穿透/未命中由调用方处理)"); + } + + [TestMethod] + public void BP_07_交易无簿记账户_限定视为不适用() + { + //tradeId 查不到 AssetId 时入参为 null:显式限定模板视为不适用,通配仍可用 + var candidates = new List { Tpl(2, Newer), Tpl(1, Older, "45") }; + var picked = MarginTemplateV2RateHelper.PickByBookScopePreference(candidates, null, out var byBook); + Assert.AreEqual(2, picked.id); + Assert.IsFalse(byBook); + } + + [TestMethod] + public void BP_08_空候选或null_返回null() + { + Assert.IsNull(MarginTemplateV2RateHelper.PickByBookScopePreference(new List(), 45, out _)); + Assert.IsNull(MarginTemplateV2RateHelper.PickByBookScopePreference(null, 45, out _)); + } + } +} diff --git a/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs index c4d864a8..67b411ce 100644 --- a/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs +++ b/UnitTestProject/Modules/CalcModules/MarginTemplateV2RateHelperFallbackTest.cs @@ -55,7 +55,7 @@ namespace YLErp.Modules.CalcModules db.SaveChanges(); } - private margin_template_v2 AddTemplate(string name, bool isDefault, bool isForClient, string tradeTypes = "收益互换", bool isValid = true, int ruleType = (int)MarginRuleTypeEnum.区间追保结构) + private margin_template_v2 AddTemplate(string name, bool isDefault, bool isForClient, string tradeTypes = "收益互换", bool isValid = true, int ruleType = (int)MarginRuleTypeEnum.区间追保结构, string bookIds = null, DateTime? valueDate = null) { var t = new margin_template_v2 { @@ -65,7 +65,8 @@ namespace YLErp.Modules.CalcModules IsValid = isValid, TradeTypes = tradeTypes, RuleType = ruleType, - ValueDate = EffectiveDate + ValueDate = valueDate ?? EffectiveDate, + BookIds = bookIds }; db.margin_template_v2.Add(t); db.SaveChanges(); @@ -179,6 +180,54 @@ namespace YLErp.Modules.CalcModules Assert.AreEqual(0.03m, rate.MaintainRate.Value); } + /// + /// 双全局默认并存(2026-08-28 裁定回归,复刻 dev 274/275 结构): + /// 同生效日下通配模板(无簿记限制)id 更新排前,限定簿记且匹配的模板排后—— + /// 原口径"先按生效日/id 取最新再验簿记"使通配恒胜、限定簿记模板永不生效; + /// 新口径簿记条件满足者优先。需一笔隔离的真实交易(有簿记账户、无交易绑定,客户无客户级绑定)。 + /// + [TestMethod] + public void TF_009_双全局默认_限定簿记匹配优先于通配() + { + var boundTradeIds = db.trade_margin_template.AsNoTracking().Select(x => x.TradeId).ToList(); + var boundClientIds = db.client_margin_template.AsNoTracking() + .Where(x => x.ClientId > 0).Select(x => x.ClientId).Distinct().ToList(); + var levelBoundNames = db.client_margin_template.AsNoTracking() + .Where(x => x.ClientId == 0 && x.ClientLevel != null && x.ClientLevel != "") + .Select(x => x.ClientLevel).Distinct().ToList(); + int tradeId, assetId, tradeClientId; + using (var clientDb = DbContextFactory.GetClientDbContext(OptUserInfo.SystemUser)) + { + var excludedLevelIds = clientDb.clientlevel.AsNoTracking() + .Where(l => levelBoundNames.Contains(l.LevelName)).Select(l => l.id).ToList(); + var excludedClientIds = clientDb.client.AsNoTracking() + .Where(c => boundClientIds.Contains(c.id) || (c.LevelId != null && excludedLevelIds.Contains(c.LevelId ?? 0))) + .Select(c => c.id).ToList(); + var picked = db.trade.AsNoTracking() + .Where(t => t.AssetId > 0 && t.ValidState != "InValid" + && !boundTradeIds.Contains(t.id) && !excludedClientIds.Contains(t.ClientId)) + .OrderByDescending(t => t.id) + .Select(t => new { t.id, t.AssetId, t.ClientId }).FirstOrDefault(); + if (picked == null) + { + Assert.Inconclusive("dev 库无可用的隔离测试交易(有簿记账户且交易/客户均无绑定)"); + return; + } + tradeId = picked.id; + assetId = picked.AssetId; + tradeClientId = picked.ClientId; + } + + //同生效日(今日,晚于 dev 既有默认模板)双全局默认:通配后建(id 更大排前) + var generic = AddTemplate(Marker + "全局通配", isDefault: true, isForClient: false, valueDate: DateTime.Today); + var bookScoped = AddTemplate(Marker + "全局限定簿记", isDefault: true, isForClient: false, bookIds: assetId.ToString(), valueDate: DateTime.Today); + + var resolved = MarginTemplateV2RateHelper.ResolveTieredTemplate(tradeId, tradeClientId, DateTime.Today, db); + Assert.IsNotNull(resolved); + Assert.IsTrue(resolved.Name.StartsWith(Marker), $"应命中本用例创建的标记模板,实际命中模板{resolved.id}(dev 出现同日/更晚生效默认模板会干扰,请重跑)"); + Assert.AreEqual(bookScoped.id, resolved.id, "限定簿记且匹配的全局默认应优先于同生效日的通配全局默认"); + } + private void AssertGlobal(margin_template_v2 resolved, margin_template_v2 expected) { Assert.IsNotNull(resolved); diff --git a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs index b6c4d466..5ef6fbec 100644 --- a/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs +++ b/YLErpDAL/Modules/MarginModule/MarginTemplateV2RateHelper.cs @@ -13,8 +13,8 @@ namespace YLErp.Modules.MarginModule /// /// 预付金模板V2 取数帮助类:按交易绑定的模板(trade_margin_template → margin_template_v2 → margin_template_detail) /// 取 初始预付金率x / 维持预付金率y,供交易确认书、预付金计算等消费点统一使用。 - /// 匹配维度:簿记账户范围(BookIds,空=全部)→ 生效日期(ValueDate 最新)→ - /// 利率债期限档(SpanConfig.BondTerm,四档,空=全部兜底)→ 标的资产类型(UnderlyingType 标志位)。 + /// 匹配维度:簿记账户范围(BookIds 显式限定且匹配者优先于空=全部的通配,同层多候选 2026-08-28 裁定)→ + /// 生效日期(ValueDate 最新)→ 利率债期限档(SpanConfig.BondTerm,四档,空=全部兜底)→ 标的资产类型(UnderlyingType 标志位)。 /// public static class MarginTemplateV2RateHelper { @@ -214,6 +214,7 @@ namespace YLErp.Modules.MarginModule /// 客户等级经 client.LevelId → clientlevel.LevelName 解析;ValueDate 最新 ≤ 业务日), /// 模板须有效、适用结构含收益互换且适用当前交易簿记账户; /// 3.全局默认 margin_template_v2(IsDefault && !IsForClient && IsValid && 适用收益互换,且适用当前簿记账户,ValueDate 最新)。 + /// 2/3 层多候选并存时按 PickByBookScopePreference 取舍:簿记条件满足者优先,其次生效日最新(2026-08-28)。 /// public static margin_template_v2 ResolveTieredTemplate(int? tradeId, int clientId, DateTime valueDate, YLContext db) { @@ -268,25 +269,25 @@ namespace YLErp.Modules.MarginModule && mt.IsValid && mt.TradeTypes.Contains("收益互换") orderby cmt.ValueDate descending, cmt.id descending select mt).ToList(); - - var ret = clientTemplate.FirstOrDefault(x => x.IsApplicableToBook(tradeAssetId)); + + //同层多候选:簿记条件满足者优先,其次绑定生效日最新(2026-08-28 裁定) + var ret = PickByBookScopePreference(clientTemplate, tradeAssetId, out var clientByBook); if (ret != null) { - logger.Info($"预付金模板取数:交易{tradeId}(客户{clientId},等级{levelName ?? "无"})命中层级=客户默认 → 模板{ret.id}"); + logger.Info($"预付金模板取数:交易{tradeId}(客户{clientId},等级{levelName ?? "无"})命中层级=客户默认 → 模板{ret.id}{(clientByBook ? "(簿记账户匹配优先)" : "")}"); return ret; } } - //3.全局默认 - var globalDefault = db.margin_template_v2.AsNoTracking() + //3.全局默认(同层多候选:簿记条件满足者优先,其次模板生效日最新——双全局默认并存时通配模板不再凭 id 最新恒胜,2026-08-28) + var globalDefault = PickByBookScopePreference(db.margin_template_v2.AsNoTracking() .Where(x => x.IsDefault && !x.IsForClient && x.IsValid && x.TradeTypes.Contains("收益互换") && x.ValueDate <= valueDate) .OrderByDescending(x => x.ValueDate) .ThenByDescending(x => x.id) - .ToList() - .FirstOrDefault(x => x.IsApplicableToBook(tradeAssetId)); + .ToList(), tradeAssetId, out var globalByBook); if (globalDefault != null) { - logger.Info($"预付金模板取数:交易{tradeId}(客户{clientId})命中层级=全局默认 → 模板{globalDefault.id}"); + logger.Info($"预付金模板取数:交易{tradeId}(客户{clientId})命中层级=全局默认 → 模板{globalDefault.id}{(globalByBook ? "(簿记账户匹配优先)" : "")}"); } return globalDefault; } @@ -298,6 +299,7 @@ namespace YLErp.Modules.MarginModule /// 1.交易绑定(ValueDate 最新 ≤ 业务日,同日多条按 id 最新兜底)找到即停,绑定指向失效模板同样停止(不回退); /// 2.客户默认(client_margin_template 按客户或按客户等级,模板须有效、适用结构含收益互换且适用当前簿记账户,先过滤有效再取绑定最新); /// 3.全局默认(IsDefault&&!IsForClient&&IsValid&&适用收益互换,按每笔交易簿记账户取ValueDate/id 最新)。 + /// 2/3 层多候选并存时按 PickByBookScopePreference 取舍:簿记条件满足者优先,其次生效日最新(2026-08-28)。 /// 返回 tradeId → 命中模板;未命中或因失效绑定停止的交易不在结果中。 /// public static Dictionary ResolveTieredTemplates(List trades, DateTime valueDate, YLContext db) @@ -400,20 +402,23 @@ namespace YLErp.Modules.MarginModule continue; } var levelName = levelNames.TryGetValue(trade.ClientId, out var clientLevelName) ? clientLevelName : null; - var clientTemplate = clientTemplateCandidates.FirstOrDefault(x => - (x.ClientId == trade.ClientId || (x.ClientId == 0 && x.ClientLevel == levelName)) - && x.Template.IsApplicableToBook(trade.AssetId)); + //同层多候选:先按客户/等级筛出候选(保持绑定生效日降序),再簿记条件满足者优先、其次生效日最新(与单笔解析一致) + var clientMatchCandidates = clientTemplateCandidates + .Where(x => x.ClientId == trade.ClientId || (x.ClientId == 0 && x.ClientLevel == levelName)) + .Select(x => x.Template) + .ToList(); + var clientTemplate = PickByBookScopePreference(clientMatchCandidates, trade.AssetId, out var clientByBook); if (clientTemplate != null) { - result[trade.id] = clientTemplate.Template; - logger.Info($"预付金模板取数:交易{trade.id}(客户{trade.ClientId},等级{levelName ?? "无"})命中层级=客户默认 → 模板{clientTemplate.Template.id}"); + result[trade.id] = clientTemplate; + logger.Info($"预付金模板取数:交易{trade.id}(客户{trade.ClientId},等级{levelName ?? "无"})命中层级=客户默认 → 模板{clientTemplate.id}{(clientByBook ? "(簿记账户匹配优先)" : "")}"); continue; } - var globalDefault = globalDefaults.FirstOrDefault(x => x.IsApplicableToBook(trade.AssetId)); + var globalDefault = PickByBookScopePreference(globalDefaults, trade.AssetId, out var globalByBook); if (globalDefault != null) { result[trade.id] = globalDefault; - logger.Info($"预付金模板取数:交易{trade.id}(客户{trade.ClientId})命中层级=全局默认 → 模板{globalDefault.id}"); + logger.Info($"预付金模板取数:交易{trade.id}(客户{trade.ClientId})命中层级=全局默认 → 模板{globalDefault.id}{(globalByBook ? "(簿记账户匹配优先)" : "")}"); } else { @@ -424,6 +429,35 @@ namespace YLErp.Modules.MarginModule return result; } + /// + /// 同层级多候选并存时的取舍(2026-08-28 裁定,纯函数单测覆盖): + /// 入参候选序须已按生效日最新在前(客户层=绑定生效日,全局层=模板生效日,同日按 id 最新)。 + /// 1.存在"簿记条件满足"的候选(BookIds 显式限定且经 IsApplicableToBook 命中当前交易簿记账户)→ 优先取之, + /// 多个命中时仍按传入顺序取生效日最新; + /// 2.否则取顺序第一个(生效日最新)。 + /// 背景:双全局默认并存(如 无预付金限定簿记 + 区间追保通配)时,原"先按生效日/id 取最新再验簿记" + /// 使通配模板恒胜、限定簿记模板永不生效(交易2572/2573 实证)。 + /// preferredByBookScope=true 表示本次由簿记匹配压过了顺序更前的通配候选(供日志辨识)。 + /// + public static margin_template_v2 PickByBookScopePreference(List candidatesByLatestFirst, int? tradeAssetId, out bool preferredByBookScope) + { + preferredByBookScope = false; + if (candidatesByLatestFirst == null || candidatesByLatestFirst.Count == 0) + { + return null; + } + + var applicable = candidatesByLatestFirst.Where(x => x.IsApplicableToBook(tradeAssetId)).ToList(); + var bookMatched = applicable.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.BookIds)); + if (bookMatched == null) + { + return applicable.FirstOrDefault(); + } + + preferredByBookScope = applicable[0].id != bookMatched.id; + return bookMatched; + } + /// /// 标的细分分类判定钩子(ETF 子类,2026-08-24 实装):基金类标的返回标的维护页维护的 /// underlying_manager.EtfSubType(取值来自数据字典"ETF 子类":国债 ETF/政金债 ETF/…/科创债 ETF/可转债 ETF,