- EodPriceService 抽出纯函数 ResolveUnderlyingIdForCode(行为不变),SyncUnderlyingIdFromCode 调用之,便于无库单测 - 新增 EodPriceUnderlyingIdGuardTest:7 用例全过,覆盖空代码/标的不存在维持原值、已一致不改动、FR007 错行(511160.SH=2173889、159111.SZ=2173890)校正为 2170838、非错配不互相覆盖 - 对应根因:GLMS-20260701 FR007 价格行 UnderlyingId 错挂导致网页查得到/结算查不到
472 lines
21 KiB
C#
472 lines
21 KiB
C#
using BaseOUDAL;
|
||
using YLErp.BLL;
|
||
using YLErp.Helpers;
|
||
|
||
namespace YLErp.Modules.EodModule
|
||
{
|
||
/// <summary>
|
||
/// 日终价格服务
|
||
/// </summary>
|
||
public class EodPriceService : YLBaseService
|
||
{
|
||
public EodPriceService(OptUserInfo userInfo) : base(userInfo)
|
||
{
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析日终价格列表的"估值日期"查询窗口。抽成 static 以便纯单测锁定行为(避免改坏)。
|
||
/// 规则:
|
||
/// - 起始日期年份 > 2000(前端传了有效日期)→ 用传入值;否则回退到 今天-1年。
|
||
/// - 结束日期年份 > 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。
|
||
/// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize)
|
||
/// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id),
|
||
/// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。
|
||
/// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId),
|
||
/// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 纯函数:根据 UnderlyingCode 校正决策。给定当前 UnderlyingId 与从 underlying_manager 解析到的正确 id,
|
||
/// 返回应使用的 UnderlyingId。UnderlyingCode 为空或库中无对应标的(resolvedId=null)时维持原值,
|
||
/// 已一致时也维持原值,仅在不一致时返回正确 id。抽成纯函数便于无数据库单测(覆盖 GLMS-20260701 FR007 错行根因)。
|
||
/// </summary>
|
||
public static int ResolveUnderlyingIdForCode(string underlyingCode, int currentId, int? resolvedId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(underlyingCode))
|
||
{
|
||
return currentId;
|
||
}
|
||
if (resolvedId == null)
|
||
{
|
||
return currentId;
|
||
}
|
||
if (resolvedId.Value == currentId)
|
||
{
|
||
return currentId;
|
||
}
|
||
return resolvedId.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。
|
||
/// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize)
|
||
/// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id),
|
||
/// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。
|
||
/// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId),
|
||
/// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。
|
||
/// </summary>
|
||
public static void SyncUnderlyingIdFromCode(YLContext db, eod_commodity_future_price row)
|
||
{
|
||
if (row == null || string.IsNullOrWhiteSpace(row.UnderlyingCode))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var um = db.underlying_manager.FirstOrDefault(u => u.UnderlyingCode == row.UnderlyingCode);
|
||
var resolvedId = um == null ? (int?)null : um.id;
|
||
var before = row.UnderlyingId;
|
||
row.UnderlyingId = ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolvedId);
|
||
if (row.UnderlyingId != before)
|
||
{
|
||
LogFactory.GetLogger("EodPrice").Info(
|
||
$"eod_commodity_future_price.UnderlyingId 与 UnderlyingCode 不一致,已自动校正: " +
|
||
$"FutureContractId={row.UnderlyingCode}, 原UnderlyingId={before}, 修正为={row.UnderlyingId}");
|
||
}
|
||
}
|
||
|
||
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));
|
||
// 债券来源:自动同步(中债, update_user 为空)归为"系统"、被手工改过的(update_user 非空)归为"人工"。
|
||
// 筛选须与后处理显示口径一致:按"系统"只命中 update_user 为空(中债自动同步)的债券;
|
||
// 按"人工"只命中被手工改过(update_user 非空)的债券;其他来源值视为无效→无命中。
|
||
if (req.DataSource == EodPriceBase.人工)
|
||
{
|
||
predicatEob = predicatEob.And(d => d.update_user != null);
|
||
}
|
||
else if (req.DataSource == EodPriceBase.系统)
|
||
{
|
||
predicatEob = predicatEob.And(d => d.update_user == null);
|
||
}
|
||
else
|
||
{
|
||
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);
|
||
|
||
foreach (var item in result.rows)
|
||
{
|
||
if (item.IsBond)
|
||
{
|
||
// 债券来源:被手工改过的(update_user 非空)→"人工";其余(中债自动同步)→"系统"。
|
||
item.DataSource = ResolveBondDisplaySource(item.UpdateUser);
|
||
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.人工;
|
||
|
||
// 入库前强制 UnderlyingId 与 UnderlyingCode(FutureContractId) 一致,避免网页/结算两套 JOIN 失同步。
|
||
SyncUnderlyingIdFromCode(DbContext, dbmodel);
|
||
|
||
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 有值)→"人工";其余(中债自动同步)→"系统"。
|
||
/// 抽为纯静态函数,便于无库单元测试。
|
||
/// </summary>
|
||
public static string ResolveBondDisplaySource(long? updateUser)
|
||
{
|
||
return updateUser.HasValue ? EodPriceBase.人工 : 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>
|
||
/// 手工改过估值时的操作人ID(china_bond_valuation.update_user)。
|
||
/// NULL = 中债自动同步;有值 = 被人手工改过(来源列显示"人工")。
|
||
/// 仅债券行可能非空,用于列表来源列区分"人工"/"系统"。
|
||
/// </summary>
|
||
public long? UpdateUser { get; set; }
|
||
}
|
||
}
|