diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs index f87252f7..26f22bfa 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs @@ -39,6 +39,10 @@ namespace YLErp.Modules.EodModule predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate); } + // 除权数据不在这里做 SQL 左连接:同一标的一天只允许一条有效除权记录, + // 但历史脏数据可能存在重复行。左连接会把一条 EOD 持仓扩成多行,进而重复 + // 参与后续风险/结算计算。先取得 EOD+BOD 的唯一持仓结果,再按标的代码匹配 + // 除权记录,可以把重复业务键暴露为 ToDictionary 异常,而不是静默扩行。 var query = from eod in DbContext.Set().Where(predicate) join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate) on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty } @@ -57,6 +61,9 @@ namespace YLErp.Modules.EodModule var datas = query.ToArray(); var diviService = new TradeModule.DealModule.DividendService(OptUser); + // 除权查询集中复用 DividendService 的有效记录条件。字典使用不区分大小写的 + // UnderlyingCode 匹配,兼容 EOD 与除权表代码大小写差异;如果同日同代码仍有 + // 多条有效记录,ToDictionary 会失败,提示迁移/结算前先清理重复数据。 var dividendDict = diviService.GetExDividendQuery(settleDate) .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase); var eodPriceProvider = new EodPriceProvider(settleDate); @@ -64,6 +71,9 @@ namespace YLErp.Modules.EodModule { var eod = data.eod; var bod = data.bod; + // 命中除权数据后仍沿用原有股票结算分支:只重算除权后的收盘价和数量, + // 并保留原 Pv 的正负方向。其他 TradeType 当前不进入该分支,避免扩大 + // 本次查询重构的业务范围。 if (dividendDict.TryGetValue(eod.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") @@ -102,6 +112,9 @@ namespace YLErp.Modules.EodModule predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate); } + // 带风险数据的重载与上面的持仓重载采用相同策略:除权记录不参与 SQL 左连接, + // 先完成 EOD、BOD、Risk 的行级关联,再在内存中按标的代码查找唯一除权记录, + // 防止除权表重复行复制风险记录。 var query = from eod in DbContext.Set().AsNoTracking().Where(predicate) join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate) on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty } @@ -123,6 +136,8 @@ namespace YLErp.Modules.EodModule var datas = query.ToArray(); var diviService = new TradeModule.DealModule.DividendService(OptUser); + // 与无风险重载保持同一数据来源、日期条件和大小写无关的代码匹配规则;重复 + // 有效记录会在这里显式失败,而不是让一条持仓对应多条风险结果。 var dividendDict = diviService.GetExDividendQuery(settleDate) .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase); var eodPriceProvider = new EodPriceProvider(settleDate); @@ -130,6 +145,7 @@ namespace YLErp.Modules.EodModule { var pos = data.eod; var bod = data.bod; + // 风险对象的除权 Pv 重算规则与上一个重载保持一致,仅在股票交易类型下执行。 if (dividendDict.TryGetValue(pos.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 033c8227..ce9a9714 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -860,6 +860,9 @@ namespace YLErp.Modules.TradeModule.DealModule private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0) { + // 业务唯一键按“标的 + 自然日”定义,而不是按完整 DateTime 定义。 + // 因此这里使用 [当天 00:00, 次日 00:00) 查询,兼容历史数据中可能存在的时分秒。 + // excludedId 用于编辑已有记录时排除自身,避免把当前记录误判为重复记录。 return DbContext.ex_dividend_info.FirstOrDefault(O => O.UnderlyingId == underlyingId && O.ExDividendDate >= exDividendDate && O.ExDividendDate < exDividendDate.AddDays(1) @@ -877,6 +880,10 @@ namespace YLErp.Modules.TradeModule.DealModule throw new ArgumentNullException(nameof(source)); } + // 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”。 + // 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值, + // 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。 + // 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。 if (source.GiveCashAmount != 0m) { target.GiveCashAmount = source.GiveCashAmount; @@ -929,6 +936,8 @@ namespace YLErp.Modules.TradeModule.DealModule return false; } + // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 + // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 var exDividendDate = item.ExDividendDate.Value.Date; var businessKey = (underlying.id, exDividendDate); if (item.id > 0 @@ -946,10 +955,13 @@ namespace YLErp.Modules.TradeModule.DealModule item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6); item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6); + // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录 + // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。 if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex)) { var preparedItem = preparedInfos[preparedIndex].Item; - // 批量保存除权信息时,检测同一业务键(标的+日期)下是否存在冲突的数据库记录。 + // 同一业务键下允许重复的是同一条记录(两个新对象都为 id=0, + // 或两个对象的 id 相同);不同 id 代表不同存量记录,不能静默合并。 if ((preparedItem.id == 0) != (item.id == 0) || preparedItem.id > 0 && item.id > 0 && preparedItem.id != item.id) { @@ -983,6 +995,8 @@ namespace YLErp.Modules.TradeModule.DealModule var item = prepared.Item; var underlying = prepared.Underlying; var itemDate = prepared.ExDividendDate; + // id>0 表示前端正在编辑指定的存量记录;id=0 时先按自然日业务键 + // 查找数据库旧记录,使“新增导入”也能与已有记录合并,而不是重复插入。 var dividend = item.id > 0 ? DbContext.ex_dividend_info.FirstOrDefault(O => O.id == item.id) : FindExDividendByBusinessKey(underlying.id, itemDate); @@ -1018,10 +1032,10 @@ namespace YLErp.Modules.TradeModule.DealModule dividend.UnderlyingCode = item.UnderlyingCode; dividend.UnderlyingId = item.UnderlyingId; dividend.ExDividendDate = item.ExDividendDate; - dividend.GiveCashAmount = item.GiveCashAmount; - dividend.RationedSharesAmount = item.RationedSharesAmount; - dividend.RationedSharesPrice = item.RationedSharesPrice; - dividend.GiveShareAmount = item.GiveShareAmount; + // 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零 + // 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入 + // 把旧的派息/送股/配股信息误清零。 + MergeNonZeroDividendValues(dividend, item); dividend.ValidStatus = true; dividend.DataSource = ExDividendDataSources.Manual; dividend.SourceUpdatedAt = sourceUpdatedAt;