Files
zszq-trs/YLErpDAL/Modules/EodModule/EodPriceService.cs
T
hjhan 02624abf75 test: 日终价格回归测试(日期窗口纯单测 + 新增可见性DB集成测试)
- EodPriceService 提取 static ResolveValueDateWindow(reqStart,reqEnd),使日期窗口逻辑可脱离数据库单测

- EodPriceDtoTest 新增4个纯单测:未传日期回退±1年、起止都填今天→仅今天窗口、显式区间原样生效、结束日今天上界为明天;锁定'页面始终5条'根因

- EodPriceGoldenReplayTest 新增 Record_NewRecordVisibility([Ignore]手动跑):直接跑 SearchUnderlyingList,锁定(a)今天+已上市可查 (b)日期0001查不出 (c)未上市查不出

- 构建0错误;EodPriceDtoTest 19通过0失败
2026-07-13 13:37:18 +08:00

429 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using System.Linq;
using BaseOUDAL;
using DocumentFormat.OpenXml.Bibliography;
using NPOI.POIFS.NIO;
using YLErp.Helpers;
namespace YLErp.Modules.EodModule
{
/// <summary>
/// 日终价格服务
/// </summary>
public class EodPriceService : YLBaseService
{
public EodPriceService(OptUserInfo userInfo) : base(userInfo)
{
}
/// <summary>
/// 解析日终价格列表的"估值日期"查询窗口。抽成 static 以便纯单测锁定行为(避免改坏)。
/// 规则:
/// - 起始日期年份 &gt; 2000(前端传了有效日期)→ 用传入值;否则回退到 今天-1年。
/// - 结束日期年份 &gt; 2000 → 用传入值+1天(闭区间转半开);否则回退到 今天+1年。
/// 注意:列表页默认把起止都设成"今天",于是窗口=[今天, 今天+1天)=仅今天 → 仅返回当天的记录
/// (即"页面始终5条"现象的真正成因,非分页/查询 bug)。要看历史须把起始日期调早。
/// </summary>
public static (DateTime start, DateTime end) ResolveValueDateWindow(DateTime reqStart, DateTime reqEnd)
{
var start = reqStart.Year > 2000 ? reqStart : DateTime.Today.AddYears(-1);
var end = reqEnd.Year > 2000 ? reqEnd.AddDays(1) : DateTime.Today.AddYears(1);
return (start, end);
}
public SearchListResult<EodUnderlyingPriceDto> SearchUnderlyingList(EodCommodityFuturePriceReq req)
{
var (valueDtStart, valueDtEnd) = ResolveValueDateWindow(req.ValueDateStart, req.ValueDateEnd);
var predicatUn = PredicateBuilder.Create<underlying_manager>(d => d.LaunchState == "1");
var predicatEoc = PredicateBuilder.Create<eod_commodity_future_price>(source => source.ValueDate >= valueDtStart && source.ValueDate < valueDtEnd);
var predicatEot = PredicateBuilder.Create<eod_stock_price>(source => source.ValueDate >= valueDtStart && source.ValueDate < valueDtEnd);
var predicatEob = PredicateBuilder.Create<ChinaBondValuation>(source => source.valuation_date >= valueDtStart && source.valuation_date < valueDtEnd);
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 != EodPriceBase.中债估值)
{
predicatEob = predicatEob.And(d => false);
}
}
if (!string.IsNullOrEmpty(req.MarketName))
{
predicatUn = predicatUn.And(d => d.MarketName == req.MarketName);
}
if (!string.IsNullOrEmpty(req.UnderlyingCode))
{
predicatUn = predicatUn.And(d => d.UnderlyingCode.Contains(req.UnderlyingCode));
}
var queryUn = DbContext.underlying_manager.Where(predicatUn).Select(n => new { n.id, n.LaunchState, n.MarketName, n.UnderlyingState, n.UnderlyingType, n.UnderlyingCode, n.UnderlyingName,n.UnderlyingInstrumentType });
var query1 = from un in queryUn
join source in DbContext.eod_commodity_future_price.Where(predicatEoc) on un.id equals source.UnderlyingId
select new EodUnderlyingPriceDto
{
IsBond=false,
id = source.id,
DataSource = source.DataSource,
// EF Core Concat 要求各分支投影成员集合完全一致:
// 债券分支设了 UpdateUser,故期货/股票分支也必须显式设(置 null),否则翻译期抛
// "The given key 'UpdateUser/DataSource' was not present in the dictionary"。
UpdateUser = (long?)null,
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
UnderlyingCode = un.UnderlyingCode,
UnderlyingName = un.UnderlyingName,
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = "CommodityFutures",
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = source.ValueDate,
SettlePrice = source.SettlePrice,
ClosePrice = source.ClosePrice,
UpdateTime = source.OptDate,
ReferencePrice = source.ReferencePrice,
SourceTime = source.SourceTime,
DeciClosePrice=0,
DeciSettlePrice = 0,
DeciReferencePrice=0
};
var query2 = from un in queryUn
join stockClose in DbContext.eod_stock_price.Where(predicatEot) on un.UnderlyingCode equals stockClose.UnderlyingCode
select new EodUnderlyingPriceDto
{
IsBond = false,
id = stockClose.id,
DataSource = stockClose.DataSource,
UpdateUser = (long?)null, // 对齐 Concat 投影成员,见 query1 注释
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
UnderlyingCode = un.UnderlyingCode,
UnderlyingName = un.UnderlyingName,
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = "Stock",
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = stockClose.ValueDate,
SettlePrice = stockClose.ClosePrice,
ClosePrice = stockClose.ClosePrice,
UpdateTime = stockClose.OptDate,
ReferencePrice = stockClose.ReferencePrice,
SourceTime = stockClose.SourceTime,
DeciClosePrice = 0,
DeciSettlePrice = 0,
DeciReferencePrice = 0
};
var query3 = from un in queryUn
join bondClose in DbContext.china_bond_valuation.Where(predicatEob) on un.UnderlyingCode equals bondClose.bond_id
select new EodUnderlyingPriceDto
{
IsBond = true,
id = bondClose.id,
UpdateUser = bondClose.update_user,
// 债券 DataSource 在后处理统一置为人名/"中债估值"(见下方 foreach);
// 此处仍须显式设 null 以对齐 Concat 各分支投影成员集合(见 query1 注释)。
DataSource = null,
LaunchState = un.LaunchState,
MarketName = un.MarketName,
UnderlyingId = un.id,
UnderlyingCode = un.UnderlyingCode,
UnderlyingName = un.UnderlyingName,
UnderlyingState = un.UnderlyingState,
UnderlyingType = un.UnderlyingType,
UnderlyingInstrumentType = un.UnderlyingInstrumentType,
RealInstrumentType = un.UnderlyingInstrumentType,
ValueDate = bondClose.valuation_date,
SettlePrice=0,
DeciSettlePrice =bondClose.net_price,
ClosePrice=0,
DeciClosePrice = bondClose.dirty_price_close,
UpdateTime = bondClose.update_time,
ReferencePrice=0,
DeciReferencePrice = bondClose.yield,
SourceTime=""
};
var unionQuery = query1.Concat(query2);
var finalQuery = unionQuery.Concat(query3);
if (string.IsNullOrEmpty(req.sidx))
{
req.sidx = "ValueDate";
req.sord = "desc";
}
var result = finalQuery.ToSearchList(req);
// 解析手工改过估值的债券操作人姓名:update_user 非空 = 被人手工改过(聚源同步为 NULL)。
// 此时来源列显示改这个人名,而非"中债估值",以便溯源到具体操作人。
var manualBondRows = result.rows.Where(r => r.IsBond && r.UpdateUser.HasValue).ToList();
Dictionary<long, string> 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);
item.ReferencePrice = Convert.ToDouble(item.DeciReferencePrice);
}
}
return result;
}
/// <summary>
/// 保存日终期货价格
/// </summary>
public eod_commodity_future_price SaveEodFuturePrice(eod_commodity_future_price req)
{
if (req is null)
{
throw new ArgumentNullException(nameof(req));
}
if (DbContext.eod_commodity_future_price.Any(n => n.id != req.id && n.UnderlyingCode == req.UnderlyingCode && n.ValueDate == req.ValueDate))
{
throw new ServiceException("已存在相同估值日期,相同合约的数据");
}
eod_commodity_future_price dbmodel;
if (req.id == 0)
{
DbContext.eod_commodity_future_price.Add(dbmodel = req);
}
else
{
dbmodel = DbContext.eod_commodity_future_price.Find(req.id);
if (dbmodel == null)
{
throw new ServiceException("数据不存在");
}
UpdateChanges(dbmodel, req);
}
SetDBModelOpt(dbmodel);
dbmodel.DataSource = EodPriceBase.人工;
DbContext.SaveChanges();
return dbmodel;
}
public ChinaBondValuation SaveBondPrice(ChinaBondValuation req)
{
if (req is null)
{
throw new ArgumentNullException(nameof(req));
}
ChinaBondValuation dbmodel;
if (req.id == 0)
{
DbContext.china_bond_valuation.Add(dbmodel = req);
}
else
{
dbmodel = DbContext.china_bond_valuation.Find(req.id);
if (dbmodel == null)
{
throw new ServiceException("数据不存在");
}
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;
}
/// <summary>
/// 标记债券估值(china_bond_valuation)的操作人。
/// 该表已有 create_user/update_user 两列(bigint),但聚源同步路径不写入,
/// 因此:NULL = 聚源/中债自动同步;有值 = 被人手工编辑(记录登录用户ID)。
/// 抽出为纯静态函数,供 SaveBondPrice 与单元测试共用。
/// </summary>
/// <param name="model">债券估值实体</param>
/// <param name="userId">当前登录用户ID</param>
/// <param name="isNew">是否为新增(true 时同时写 create_user)</param>
public static void StampBondOperator(ChinaBondValuation model, int userId, bool isNew)
{
model.update_user = userId;
if (isNew)
{
model.create_user = userId;
}
}
/// <summary>
/// 债券来源列该显示什么:
/// - update_user 有值(被人手工改过)且能解析到操作人姓名 → 显示改这个人名,便于溯源;
/// - 否则(聚源/中债自动同步,update_user 为 NULL)→ 固定显示"中债估值"。
/// 抽为纯静态函数,与解析姓名所需的 sys_user 查询解耦,便于无库单元测试。
/// </summary>
/// <param name="updateUser">china_bond_valuation.update_userNULL = 自动同步)</param>
/// <param name="operatorNameMap">update_user(ID) → 操作人姓名 的字典(由调用方按需从 sys_user 解析)</param>
public static string ResolveBondDisplaySource(long? updateUser, IDictionary<long, string> operatorNameMap)
{
if (updateUser.HasValue && operatorNameMap != null
&& operatorNameMap.TryGetValue(updateUser.Value, out var opName) && !string.IsNullOrEmpty(opName))
{
return opName;
}
return EodPriceBase.中债估值;
}
/// <summary>
/// 保存日终股票价格
/// </summary>
public eod_stock_price SaveEodStockPrice(eod_stock_price req)
{
if (req is null)
{
throw new ArgumentNullException(nameof(req));
}
if (DbContext.eod_stock_price.Any(n => n.id != req.id && n.UnderlyingCode == req.UnderlyingCode && n.ValueDate == req.ValueDate))
{
throw new ServiceException("已存在相同估值日期,相同合约的数据");
}
eod_stock_price dbmodel;
if (req.id == 0)
{
DbContext.eod_stock_price.Add(dbmodel = req);
}
else
{
dbmodel = DbContext.eod_stock_price.Find(req.id);
if (dbmodel == null)
{
throw new ServiceException("数据不存在");
}
UpdateChanges(dbmodel, req);
}
SetDBModelOpt(dbmodel);
dbmodel.DataSource = EodPriceBase.人工;
DbContext.SaveChanges();
return dbmodel;
}
}
/// <summary>
///
/// </summary>
public class EodCommodityFuturePriceReq : BaseSearchReq
{
/// <summary>
/// 数据来源
/// </summary>
public string DataSource { get; set; }
public string MarketName { get; set; }
public string LaunchState { get; set; }
/// <summary>
/// 标的代码
/// </summary>
public string UnderlyingCode { get; set; }
public DateTime ValueDateStart { get; set; }
public DateTime ValueDateEnd { get; set; }
}
public class EodUnderlyingPriceDto
{
public string EncryptId
{
get
{
return DataProtectHelper.Encrypt(id.ToString());
}
}
public long id { get; set; }
public DateTime ValueDate { get; set; }
public double SettlePrice { get; set; }
public double ClosePrice { get; set; }
public double? ReferencePrice { get; set; }
public string DataSource { get; set; }
public string UnderlyingType { get; set; }
public string UnderlyingInstrumentType { get; set; }
/// <summary>
/// 真实标的种类(取自 underlying_manager),仅供列表"标的种类"列显示。
/// UnderlyingInstrumentType 仍作为"存储表路由键"使用,二者解耦,避免改动历史路由逻辑。
/// </summary>
public string RealInstrumentType { get; set; }
public string UnderlyingInstrumentTypeCn => ConsGlobal.InstrumentType.GetDesc(RealInstrumentType ?? UnderlyingInstrumentType);
public string UnderlyingState { get; set; }
public string MarketName { get; set; }
public string LaunchState { get; set; }
public int UnderlyingId { get; set; }
public string UnderlyingCode { get; set; }
public string UnderlyingName { get; set; }
public DateTime? UpdateTime { get; set; }
public string SourceTime { get; set; }
public decimal? DeciSettlePrice { get; set; }
public decimal? DeciClosePrice { get; set; }
public decimal? DeciReferencePrice { get; set; }
public bool IsBond { get; set; }
/// <summary>
/// 手工改过估值时的操作人IDchina_bond_valuation.update_user)。
/// NULL = 聚源/中债自动同步(无人手工维护);有值 = 被人手工改过、可溯源。
/// 仅债券行可能非空,用于列表来源列显示改这个人名而非"中债估值"。
/// </summary>
public long? UpdateUser { get; set; }
}
}