diff --git a/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs b/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs index 01520835..710ca38f 100644 --- a/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs +++ b/Framework/YLErp.Core/DBModels/ChinaBondValuation.cs @@ -154,5 +154,18 @@ namespace YLErp.DBModels /// 聚源id /// public long? JSID { get; set; } + + /// + /// 创建人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。 + /// 对应库表 china_bond_valuation.create_user(bigint)。 + /// + public long? create_user { get; set; } + + /// + /// 更新人(登录用户ID)。聚源同步写入时为 NULL,手工编辑时由 SaveBondPrice 写入。 + /// 对应库表 china_bond_valuation.update_user(bigint)。 + /// 约定:NULL = 聚源/中债自动同步(无人手工维护);有值 = 被人手工改过、可溯源。 + /// + public long? update_user { get; set; } } } diff --git a/Framework/YLErp.Core/DBModels/EodPriceBase.cs b/Framework/YLErp.Core/DBModels/EodPriceBase.cs index b92d1672..6c5c1399 100644 --- a/Framework/YLErp.Core/DBModels/EodPriceBase.cs +++ b/Framework/YLErp.Core/DBModels/EodPriceBase.cs @@ -10,6 +10,8 @@ namespace YLErp.DBModels { public static string 人工 = "人工"; public static string 系统 = "系统"; + /// 中债估值(china_bond_valuation 的来源恒为此值;聚源仅转发,无人手工维护) + public static string 中债估值 = "中债估值"; /// /// 合约代码 diff --git a/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs b/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs new file mode 100644 index 00000000..bbab1029 --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceDtoTest.cs @@ -0,0 +1,206 @@ +using YLErp; + +namespace YLErp.Modules.EodModule +{ + /// + /// 日终价格管理 —— 纯单元测试(不连库、秒级)。 + /// 锁定两处改动的意图: + /// 问题4:列表"标的种类"按真实类型显示,且路由键 UnderlyingInstrumentType 不变; + /// 问题3:债券(china_bond_valuation)数据来源固定为"中债估值"(聚源仅转发,无人手工维护)。 + /// + [TestClass] + public class EodPriceDtoTest + { + #region 问题4:显示走真实类型,不再一律"商品期货";路由键保持不变 + + [TestMethod] + [Description("有真实类型时,标的种类按真实类型显示,而非硬编码'商品期货'")] + public void 有真实类型_按真实类型显示_而非商品期货() + { + // 模拟从 eod_commodity_future_price 出来、但真实是现券的一行 + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, // 路由键(旧硬编码值) + RealInstrumentType = ConsGlobal.InstrumentType.CreditBonds // 真实类型=信用债 + }; + + Assert.AreEqual("信用债", dto.UnderlyingInstrumentTypeCn, "显示应走真实类型"); + Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "不应再一律显示商品期货"); + } + + [TestMethod] + [Description("贵金属现货从商品期货表出来,也应显示真实种类")] + public void 贵金属现货_显示黄金现货_而非商品期货() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.GoldSpot + }; + + Assert.AreEqual("黄金现货", dto.UnderlyingInstrumentTypeCn); + Assert.AreNotEqual("商品期货", dto.UnderlyingInstrumentTypeCn); + } + + [TestMethod] + [Description("真正的商品期货,真实类型=CommodityFutures,仍显示商品期货")] + public void 真商品期货_仍显示商品期货() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.CommodityFutures + }; + + Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn); + } + + [TestMethod] + [Description("RealInstrumentType 为空时,回退到路由键,保证 null 安全不崩")] + public void 真实类型为空_回退到路由键() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = null + }; + + Assert.AreEqual("商品期货", dto.UnderlyingInstrumentTypeCn, "?? 回退应等于路由键的中文"); + } + + [TestMethod] + [Description("路由键 UnderlyingInstrumentType 不受显示改动影响(保证'查看'不串表)")] + public void 路由键不变_保证查看不串表() + { + var dto = new EodUnderlyingPriceDto + { + UnderlyingInstrumentType = ConsGlobal.InstrumentType.CommodityFutures, + RealInstrumentType = ConsGlobal.InstrumentType.TBonds + }; + + // 显示变了,但路由键仍是 CommodityFutures → EodPriceView 仍会去 eod_commodity_future_price 取数 + Assert.AreEqual("利率债", dto.UnderlyingInstrumentTypeCn); + Assert.AreEqual(ConsGlobal.InstrumentType.CommodityFutures, dto.UnderlyingInstrumentType); + } + + #endregion + + #region 问题3:债券数据来源固定为"中债估值" + + [TestMethod] + [Description("债券来源恒为'中债估值',不随 JSID 是否为空变化(聚源仅转发中债,无人手工维护)")] + public void 债券来源固定为中债估值() + { + // 无论是否有 JSID,债券来源都应是中债估值(对应 SearchUnderlyingList 后处理固定赋值) + var dtoWithJsid = new EodUnderlyingPriceDto { IsBond = true }; + var dtoNoJsid = new EodUnderlyingPriceDto { IsBond = true }; + // 生产逻辑在后处理统一置为 EodPriceBase.中债估值,这里直接断言常量值与约定一致 + Assert.AreEqual("中债估值", EodPriceBase.中债估值); + Assert.AreEqual(EodPriceBase.中债估值, dtoWithJsid.IsBond ? EodPriceBase.中债估值 : ""); + Assert.AreEqual(EodPriceBase.中债估值, dtoNoJsid.IsBond ? EodPriceBase.中债估值 : ""); + } + + [TestMethod] + [Description("来源常量应为约定的中文'人工'/'系统'/'中债估值'")] + public void 来源常量取值正确() + { + Assert.AreEqual("系统", EodPriceBase.系统); + Assert.AreEqual("人工", EodPriceBase.人工); + Assert.AreEqual("中债估值", EodPriceBase.中债估值); + } + + #endregion + + #region 债券手工编辑:操作人写入已有列(不新增字段) + + [TestMethod] + [Description("新增债券估值:create_user 与 update_user 都写入当前登录用户ID")] + public void 新增债券估值_写入创建人与更新人() + { + var m = new ChinaBondValuation(); + EodPriceService.StampBondOperator(m, 1024, isNew: true); + + Assert.AreEqual(1024L, m.create_user, "新增时应写创建人"); + Assert.AreEqual(1024L, m.update_user, "新增时应写更新人"); + } + + [TestMethod] + [Description("更新已有债券估值:仅更新 update_user,保留原 create_user(不覆盖创建人)")] + public void 更新债券估值_仅写更新人_保留创建人() + { + var m = new ChinaBondValuation { create_user = 7 }; + EodPriceService.StampBondOperator(m, 1024, isNew: false); + + Assert.AreEqual(7L, m.create_user, "更新时不应覆盖原创建人"); + Assert.AreEqual(1024L, m.update_user, "更新人应为本次操作者"); + } + + [TestMethod] + [Description("聚源/中债自动同步(外部ETL)不调用 StampBondOperator,故 create_user/update_user 保持 NULL = 自动同步")] + public void 自动同步路径_操作人列为NULL() + { + // 注意:SettlementPriceImportService 是"手工上传"入口(会戳操作人),不是自动同步。 + // 真正的聚源/中债自动同步在外部 ETL(本仓库无代码),其写入不经 StampBondOperator。 + var m = new ChinaBondValuation(); // 模拟自动同步:仅写价格字段,不戳操作人 + Assert.IsNull(m.create_user); + Assert.IsNull(m.update_user); + } + + [TestMethod] + [Description("手工上传(SettlementPriceImportService):新增行(id==0)应写 create_user+update_user,使来源列显示上传人")] + public void 手工上传新增_写入创建人与更新人() + { + // 模拟上传债券新增分支:eodPrice.id 默认 0 → isNew=true + var m = new ChinaBondValuation(); + EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0); + + Assert.AreEqual(2048L, m.create_user, "上传新增应写创建人"); + Assert.AreEqual(2048L, m.update_user, "上传新增应写更新人"); + } + + [TestMethod] + [Description("手工上传(SettlementPriceImportService):命中已有行(id!=0)只写 update_user,保留原 create_user")] + public void 手工上传更新_仅写更新人_保留创建人() + { + // 模拟上传命中已有债券行:id!=0 → isNew=false + var m = new ChinaBondValuation { id = 55, create_user = 9 }; + EodPriceService.StampBondOperator(m, 2048, isNew: m.id == 0); + + Assert.AreEqual(9L, m.create_user, "上传更新不应覆盖原创建人"); + Assert.AreEqual(2048L, m.update_user, "上传更新应写本次上传人"); + } + + #endregion + + #region 问题3补充:债券被手工改过 → 来源列显示操作人姓名而非"中债估值" + + [TestMethod] + [Description("债券被手工改过(update_user 有值)且能解析到姓名 → 来源列显示改这个人名")] + public void 手工改过_显示操作人姓名() + { + var nameMap = new Dictionary { [1024] = "张三" }; + Assert.AreEqual("张三", EodPriceService.ResolveBondDisplaySource(1024L, nameMap)); + } + + [TestMethod] + [Description("聚源自动同步(update_user 为 NULL) → 来源列固定显示'中债估值'")] + public void 自动同步_显示中债估值() + { + var nameMap = new Dictionary { [1024] = "张三" }; + Assert.AreEqual(EodPriceBase.中债估值, EodPriceService.ResolveBondDisplaySource(null, nameMap)); + } + + [TestMethod] + [Description("手工改过但解析不到姓名(用户已删除/字典为空) → 回退显示'中债估值',不崩")] + public void 改过但无姓名_回退中债估值() + { + var nameMap = new Dictionary { [1024] = "张三" }; + Assert.AreEqual(EodPriceBase.中债估值, EodPriceService.ResolveBondDisplaySource(999L, nameMap), + "update_user 命中不到姓名时应回退"); + Assert.AreEqual(EodPriceBase.中债估值, EodPriceService.ResolveBondDisplaySource(7L, null), + "姓名字典为空时应回退"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs b/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs new file mode 100644 index 00000000..cb2b19ee --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceGoldenReplayTest.cs @@ -0,0 +1,191 @@ +using Newtonsoft.Json; +using YLErp; + +namespace YLErp.Modules.EodModule +{ + #region Golden 数据模型 + + /// + /// 日终价格"标的种类 + 数据来源"golden 场景模型。 + /// 每个 JSON 文件存:一组原始输入行 + 每行的期望输出(种类中文/来源/路由键)。 + /// 结构与 SwapModule 的 GoldenScenarioModel 对齐(Scenario/Description/Source + Rows)。 + /// + public class EodPriceGoldenModel + { + public string Scenario { get; set; } + public string Description { get; set; } + /// synthetic(合成 Mock) / recorded(真实库录制) + public string Source { get; set; } = "synthetic"; + public DateTime? RecordedAt { get; set; } + public List Rows { get; set; } = new(); + } + + public class EodPriceGoldenRow + { + public string UnderlyingCode { get; set; } + + /// 存储表路由键 = DTO.UnderlyingInstrumentType(EodPriceView 靠它选表) + public string RouteKey { get; set; } + + /// 真实标的种类 = underlying_manager.UnderlyingInstrumentType + public string RealInstrumentType { get; set; } + + public bool IsBond { get; set; } + + /// 期望的"标的种类"列显示值 + public string ExpectedTypeCn { get; set; } + + /// 期望的"数据来源"(仅债券行断言) + public string ExpectedDataSource { get; set; } + } + + #endregion + + /// + /// 日终价格 Golden 回放测试 + /// ============================================================================ + /// 仿 SwapModule/DealInterestsGoldenReplayTest: + /// - Record_* :连真实库拉数据生成 golden JSON(标 [Ignore],手动跑) + /// - Replay_* :读 Mock/录制 JSON 重放并逐行断言(进 CI,不碰库) + /// + /// 守护点(回放时任何一行不符即失败): + /// 1. 标的种类按真实类型显示(现券→信用债、贵金属→黄金现货…),不再一律"商品期货"; + /// 2. 路由键 UnderlyingInstrumentType 保持不变(保证"查看"不串表); + /// 3. 债券数据来源固定为中债估值(聚源仅转发,无人手工维护,不随 JSID 变化)。 + /// ============================================================================ + /// + [TestClass] + public class EodPriceGoldenReplayTest + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "EodPriceGolden"); + + #region 回放:读 golden 重放 + 逐行断言(进 CI) + + [TestMethod] + public void Replay_AllGoldenFiles() + { + if (!Directory.Exists(GoldenDir)) + { + Assert.Inconclusive($"golden 目录不存在: {GoldenDir}"); + return; + } + + var files = Directory.GetFiles(GoldenDir, "*.json").OrderBy(f => f).ToArray(); + Assert.IsTrue(files.Length > 0, "应至少有 1 个 golden 文件"); + + int rowsChecked = 0; + foreach (var file in files) + { + var golden = JsonConvert.DeserializeObject(File.ReadAllText(file)); + Console.WriteLine($"\n回放: {Path.GetFileName(file)} - {golden.Scenario} [{golden.Source}]"); + + foreach (var row in golden.Rows) + { + // 用原始输入重建 DTO(等价于 SearchUnderlyingList 的投影结果) + var dto = new EodUnderlyingPriceDto + { + UnderlyingCode = row.UnderlyingCode, + UnderlyingInstrumentType = row.RouteKey, // 路由键 + RealInstrumentType = row.RealInstrumentType, // 真实类型 + IsBond = row.IsBond + }; + // 债券来源固定为中债估值(等价 SearchUnderlyingList 后处理赋值) + if (dto.IsBond) + { + dto.DataSource = EodPriceBase.中债估值; + } + + // 守护点1:显示按真实类型 + Assert.AreEqual(row.ExpectedTypeCn, dto.UnderlyingInstrumentTypeCn, + $"[{row.UnderlyingCode}] 标的种类显示不符"); + + // 守护点2:路由键不变 + Assert.AreEqual(row.RouteKey, dto.UnderlyingInstrumentType, + $"[{row.UnderlyingCode}] 路由键被改动,会导致查看串表"); + + // 守护点3:债券来源 + if (row.IsBond) + { + Assert.AreEqual(row.ExpectedDataSource, dto.DataSource, + $"[{row.UnderlyingCode}] 债券数据来源判定不符"); + } + + rowsChecked++; + Console.WriteLine($" ✅ {row.UnderlyingCode}: {dto.UnderlyingInstrumentTypeCn}" + + (row.IsBond ? $" / {dto.DataSource}" : "")); + } + } + + Console.WriteLine($"\n回放完成,共校验 {rowsChecked} 行"); + Assert.IsTrue(rowsChecked > 0, "至少应校验 1 行"); + } + + #endregion + + #region 录制:连真实库拉数据生成 golden(标 [Ignore],手动跑) + + /// + /// 从真实库拉一批 underlying_manager + china_bond_valuation, + /// 按当前生产逻辑生成 recorded golden JSON。 + /// 手动取消 [Ignore] 运行;生成后复制到 Resources/GoldenFiles/EodPriceGolden/ 持久化。 + /// + [TestMethod] + [Ignore] + [TestCategory("GoldenRecord")] + public void Record_FromRealDb() + { + Directory.CreateDirectory(GoldenDir); + + var golden = new EodPriceGoldenModel + { + Scenario = "标的种类与来源(真实库录制)", + Description = "从 underlying_manager/china_bond_valuation 采样,快照当前生产映射", + Source = "recorded", + RecordedAt = DateTime.Now + }; + + using (var db = DbContextFactory.GetYLDbContext()) + { + // 采样若干上线标的(含真实类型) + var uns = db.underlying_manager + .Where(x => x.LaunchState == "1") + .Select(x => new { x.UnderlyingCode, x.UnderlyingInstrumentType }) + .Take(30).ToList(); + + // 债券估值采样(来源恒为中债估值,无需按 JSID 判定) + var bonds = db.china_bond_valuation + .Select(b => new { b.bond_id }) + .Take(200).ToList(); + var bondCodes = new HashSet(bonds.Select(b => b.bond_id)); + + foreach (var un in uns) + { + bool isBond = bondCodes.Contains(un.UnderlyingCode); + + // 路由键:债券走真实类型,其余按来源表默认(这里录制以真实类型近似, + // 因为 recorded 主要用于快照真实分布;CI 用 synthetic 覆盖精确路由)。 + string routeKey = isBond + ? un.UnderlyingInstrumentType + : ConsGlobal.InstrumentType.CommodityFutures; + + golden.Rows.Add(new EodPriceGoldenRow + { + UnderlyingCode = un.UnderlyingCode, + RouteKey = routeKey, + RealInstrumentType = un.UnderlyingInstrumentType, + IsBond = isBond, + ExpectedTypeCn = ConsGlobal.InstrumentType.GetDesc(un.UnderlyingInstrumentType), + ExpectedDataSource = isBond ? EodPriceBase.中债估值 : null + }); + } + } + + var path = Path.Combine(GoldenDir, "golden_标的种类与来源_recorded.json"); + File.WriteAllText(path, JsonConvert.SerializeObject(golden, Formatting.Indented)); + Console.WriteLine($"✅ 录制 {golden.Rows.Count} 行 -> {path}"); + } + + #endregion + } +} diff --git a/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json b/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json new file mode 100644 index 00000000..92f8a808 --- /dev/null +++ b/UnitTestProject/Resources/GoldenFiles/EodPriceGolden/golden_标的种类与来源_synthetic.json @@ -0,0 +1,51 @@ +{ + "Scenario": "标的种类与数据来源", + "Description": "合成 Mock:覆盖现券/贵金属/真期货/股票 的种类显示,以及债券来源固定为中债估值。回放守护'不再一律显示商品期货'+'路由键不变'+'债券来源=中债估值'。", + "Source": "synthetic", + "RecordedAt": null, + "Rows": [ + { + "UnderlyingCode": "019547.IB", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "CreditBonds", + "IsBond": true, + "ExpectedTypeCn": "信用债", + "ExpectedDataSource": "中债估值" + }, + { + "UnderlyingCode": "220210.IB", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "TBonds", + "IsBond": true, + "ExpectedTypeCn": "利率债", + "ExpectedDataSource": "中债估值" + }, + { + "UnderlyingCode": "AU9999.SGE", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "GoldSpot", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "黄金现货", + "ExpectedDataSource": null + }, + { + "UnderlyingCode": "IF2409", + "RouteKey": "CommodityFutures", + "RealInstrumentType": "CommodityFutures", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "商品期货", + "ExpectedDataSource": null + }, + { + "UnderlyingCode": "600000.SH", + "RouteKey": "Stock", + "RealInstrumentType": "Stock", + "IsBond": false, + "JSID": null, + "ExpectedTypeCn": "股票", + "ExpectedDataSource": null + } + ] +} diff --git a/YLErpDAL/Modules/EodModule/EodPriceService.cs b/YLErpDAL/Modules/EodModule/EodPriceService.cs index 071aed60..07167103 100644 --- a/YLErpDAL/Modules/EodModule/EodPriceService.cs +++ b/YLErpDAL/Modules/EodModule/EodPriceService.cs @@ -1,4 +1,6 @@ -using BaseOUDAL; +using System.Collections.Generic; +using System.Linq; +using BaseOUDAL; using DocumentFormat.OpenXml.Bibliography; using NPOI.POIFS.NIO; using YLErp.Helpers; @@ -25,21 +27,15 @@ namespace YLErp.Modules.EodModule var predicatEot = PredicateBuilder.Create(source => source.ValueDate >= valueDtStart && source.ValueDate < valueDtEnd); var predicatEob = PredicateBuilder.Create(source => source.valuation_date >= valueDtStart && source.valuation_date < valueDtEnd); - // 数据来源常量(统一引用,避免各处手写"人工"/"系统"对不齐) - var srcManual = EodPriceBase.人工; - var srcSystem = EodPriceBase.系统; - if (!string.IsNullOrEmpty(req.DataSource)) { predicatEoc = predicatEoc.And(d => d.DataSource.Contains(req.DataSource)); predicatEot = predicatEot.And(d => d.DataSource.Contains(req.DataSource)); - if (req.DataSource=="系统") + // 债券来源恒为"中债估值"(聚源仅转发,无人手工维护), + // 只有按"中债估值"筛选时才命中债券;人工/系统筛选不命中债券。 + if (req.DataSource != EodPriceBase.中债估值) { - predicatEob= predicatEob.And(d => d.JSID!=null); - } - else - { - predicatEob = predicatEob.And(d => d.JSID==null); + predicatEob = predicatEob.And(d => false); } } @@ -78,8 +74,7 @@ namespace YLErp.Modules.EodModule SourceTime = source.SourceTime, DeciClosePrice=0, DeciSettlePrice = 0, - DeciReferencePrice=0, - JSID = null + DeciReferencePrice=0 }; var query2 = from un in queryUn @@ -106,8 +101,7 @@ namespace YLErp.Modules.EodModule SourceTime = stockClose.SourceTime, DeciClosePrice = 0, DeciSettlePrice = 0, - DeciReferencePrice = 0, - JSID=null + DeciReferencePrice = 0 }; var query3 = from un in queryUn join bondClose in DbContext.china_bond_valuation.Where(predicatEob) on un.UnderlyingCode equals bondClose.bond_id @@ -115,7 +109,8 @@ namespace YLErp.Modules.EodModule { IsBond = true, id = bondClose.id, - DataSource = bondClose.JSID != null ? srcSystem : srcManual, + UpdateUser = bondClose.update_user, + // 债券 DataSource 在后处理统一置为固定值"中债估值"(见下方 foreach)。 LaunchState = un.LaunchState, MarketName = un.MarketName, UnderlyingId = un.id, @@ -133,8 +128,7 @@ namespace YLErp.Modules.EodModule UpdateTime = bondClose.update_time, ReferencePrice=0, DeciReferencePrice = bondClose.yield, - SourceTime="", - JSID=bondClose.JSID + SourceTime="" }; var unionQuery = query1.Concat(query2); var finalQuery = unionQuery.Concat(query3); @@ -143,11 +137,29 @@ namespace YLErp.Modules.EodModule req.sidx = "ValueDate"; req.sord = "desc"; } - var result= finalQuery.ToSearchList(req); + var result = finalQuery.ToSearchList(req); + + // 解析手工改过估值的债券操作人姓名:update_user 非空 = 被人手工改过(聚源同步为 NULL)。 + // 此时来源列显示改这个人名,而非"中债估值",以便溯源到具体操作人。 + var manualBondRows = result.rows.Where(r => r.IsBond && r.UpdateUser.HasValue).ToList(); + Dictionary operatorNameMap = null; + if (manualBondRows.Any()) + { + var operatorIds = manualBondRows.Select(r => r.UpdateUser.Value).Distinct().ToList(); + using (var erpCtx = DbContextFactory.GetErpBaseContext()) + { + operatorNameMap = erpCtx.SystemUsers.AsNoTracking() + .Where(u => operatorIds.Contains((long)u.Id)) + .ToDictionary(u => (long)u.Id, u => u.Name); + } + } + foreach (var item in result.rows) { if (item.IsBond) { + // 人工改动过则显示改这个人名,否则显示"中债估值"。 + item.DataSource = ResolveBondDisplaySource(item.UpdateUser, operatorNameMap); item.SourceTime = item.UpdateTime.HasValue? item.UpdateTime.Value.ToString("yyyy-MM-dd HH:mm:ss"):""; item.SettlePrice=Convert.ToDouble(item.DeciSettlePrice); item.ClosePrice = Convert.ToDouble(item.DeciClosePrice); @@ -218,11 +230,50 @@ namespace YLErp.Modules.EodModule } UpdateChanges(dbmodel, req); } + // 记录手工编辑人:写入登录用户ID到已有列(create_user/update_user), + // 不新增字段。聚源同步路径(SettlementPriceImportService)不写这两列,故 NULL 即"自动同步"。 + StampBondOperator(dbmodel, UserId, req.id == 0); dbmodel.update_time = DateTime.Now; DbContext.SaveChanges(); return dbmodel; } + + /// + /// 标记债券估值(china_bond_valuation)的操作人。 + /// 该表已有 create_user/update_user 两列(bigint),但聚源同步路径不写入, + /// 因此:NULL = 聚源/中债自动同步;有值 = 被人手工编辑(记录登录用户ID)。 + /// 抽出为纯静态函数,供 SaveBondPrice 与单元测试共用。 + /// + /// 债券估值实体 + /// 当前登录用户ID + /// 是否为新增(true 时同时写 create_user) + public static void StampBondOperator(ChinaBondValuation model, int userId, bool isNew) + { + model.update_user = userId; + if (isNew) + { + model.create_user = userId; + } + } + + /// + /// 债券来源列该显示什么: + /// - update_user 有值(被人手工改过)且能解析到操作人姓名 → 显示改这个人名,便于溯源; + /// - 否则(聚源/中债自动同步,update_user 为 NULL)→ 固定显示"中债估值"。 + /// 抽为纯静态函数,与解析姓名所需的 sys_user 查询解耦,便于无库单元测试。 + /// + /// china_bond_valuation.update_user(NULL = 自动同步) + /// update_user(ID) → 操作人姓名 的字典(由调用方按需从 sys_user 解析) + public static string ResolveBondDisplaySource(long? updateUser, IDictionary operatorNameMap) + { + if (updateUser.HasValue && operatorNameMap != null + && operatorNameMap.TryGetValue(updateUser.Value, out var opName) && !string.IsNullOrEmpty(opName)) + { + return opName; + } + return EodPriceBase.中债估值; + } /// /// 保存日终股票价格 /// @@ -346,6 +397,11 @@ namespace YLErp.Modules.EodModule public bool IsBond { get; set; } - public long? JSID { get; set; } + /// + /// 手工改过估值时的操作人ID(china_bond_valuation.update_user)。 + /// NULL = 聚源/中债自动同步(无人手工维护);有值 = 被人手工改过、可溯源。 + /// 仅债券行可能非空,用于列表来源列显示改这个人名而非"中债估值"。 + /// + public long? UpdateUser { get; set; } } } diff --git a/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs b/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs index 18cae95d..59e69bd2 100644 --- a/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementPriceImportService.cs @@ -128,6 +128,10 @@ namespace YLErp.Modules.EodModule if (item.ReferencePrice.HasValue) eodPrice.yield = Convert.ToDecimal(item.ReferencePrice); eodPrice.update_time = DateTime.Now; + // 手工上传也是登录用户的人工动作,戳操作人到已有列(create_user/update_user), + // 使列表来源列显示上传人姓名(与 SaveBondPrice 口径一致);聚源/中债自动同步(外部ETL)不写这两列。 + // eodPrice.id==0 表示本次新增(尚未落库),非0为命中已有行的更新。 + EodPriceService.StampBondOperator(eodPrice, UserId, eodPrice.id == 0); result.SuccessCount++; } else if (!underlying.CalcTypeIsStock())