diff --git a/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs b/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs new file mode 100644 index 00000000..babd8e9e --- /dev/null +++ b/UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; + +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class DividendBasketQueryTranslationTest + { + [TestMethod] + public void DividendBasketQueriesUseEfTranslatableCommodityCondition() + { + var source = ReadDividendServiceSource(); + var addDividendQuery = ExtractQuery( + source, + "var basketList =", + "IEnumerable priceList = null;"); + var executeStatusQuery = ExtractQuery( + source, + "var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(", + ").Select(O => O.UnderlyingCode).ToArray();"); + + AssertQueryUsesCommodityCondition(addDividendQuery, "AddDividendInfos"); + AssertQueryUsesCommodityCondition(executeStatusQuery, "checkDividendInfoExecuteStatus"); + } + + private static void AssertQueryUsesCommodityCondition(string query, string methodName) + { + Assert.IsFalse( + query.Contains("IsBasket()", StringComparison.Ordinal), + $"{methodName} must not put IsBasket() in an IQueryable predicate."); + Assert.IsTrue( + query.Contains("O.CommodityCode == \"篮子标的\"", StringComparison.Ordinal), + $"{methodName} must filter baskets with the EF-translatable CommodityCode condition."); + } + + private static string ExtractQuery(string source, string startMarker, string endMarker) + { + var start = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, $"Could not find query marker: {startMarker}"); + var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal); + Assert.IsTrue(end >= 0, $"Could not find query end marker: {endMarker}"); + return source.Substring(start, end + endMarker.Length - start); + } + + private static string ReadDividendServiceSource() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var path = Path.Combine( + directory.FullName, + "YLErpDAL", + "Modules", + "TradeModule", + "DealModule", + "DividendService.cs"); + if (File.Exists(path)) + { + return File.ReadAllText(path); + } + directory = directory.Parent; + } + + Assert.Fail("Could not locate DividendService.cs from the test output directory."); + return string.Empty; + } + } +} diff --git a/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs b/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs index aaec3654..95b4d343 100644 --- a/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs +++ b/UnitTestProject/Modules/EodModule/EodSettlementTaskTest.cs @@ -123,7 +123,7 @@ namespace YLErp.Modules.EodModule { UnderlyingCode = "002043.SZ", ExDividendDate = new DateTime(2020, 7, 6), - GiveCashAmount = 2.5, + GiveCashAmount = 2.5m, GiveShareAmount = 0, RationedSharesAmount = 0, RationedSharesPrice = 0, @@ -135,7 +135,7 @@ namespace YLErp.Modules.EodModule { UnderlyingCode = "600406.SH", ExDividendDate = new DateTime(2020, 7, 8), - GiveCashAmount = 2.9, + GiveCashAmount = 2.9m, GiveShareAmount = 0, RationedSharesAmount = 0, RationedSharesPrice = 0, @@ -147,7 +147,7 @@ namespace YLErp.Modules.EodModule { UnderlyingCode = "600406.SH", ExDividendDate = new DateTime(2020, 7, 8), - GiveCashAmount = 2.9, + GiveCashAmount = 2.9m, GiveShareAmount = 0, RationedSharesAmount = 0, RationedSharesPrice = 0, @@ -159,7 +159,7 @@ namespace YLErp.Modules.EodModule { UnderlyingCode = "601021.SH", ExDividendDate = new DateTime(2020, 7, 8), - GiveCashAmount = 2.0006, + GiveCashAmount = 2.0006m, GiveShareAmount = 0, RationedSharesAmount = 0, RationedSharesPrice = 0, @@ -171,7 +171,7 @@ namespace YLErp.Modules.EodModule { UnderlyingCode = "300001.SZ", ExDividendDate = new DateTime(2020, 7, 13), - GiveCashAmount = 0.2, + GiveCashAmount = 0.2m, GiveShareAmount = 0, RationedSharesAmount = 0, RationedSharesPrice = 0, diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index ae77cd50..bbf76a41 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -5,6 +5,12 @@ using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels { + public static class ExDividendDataSources + { + public const string Manual = "Manual"; + public const string MarketData = "MarketData"; + } + [Table("ex_dividend_info")] public class ex_dividend_info : DBModelWithOperator { @@ -35,29 +41,41 @@ namespace YLErp.DBModels /// 派息金额 /// [DisplayName("派息金额")] - public double GiveCashAmount { get; set; } + public decimal GiveCashAmount { get; set; } /// /// 送股手数 /// [DisplayName("送股股数")] - public double GiveShareAmount { get; set; } + public decimal GiveShareAmount { get; set; } /// /// 配股手数 /// [DisplayName("配股股数")] - public double RationedSharesAmount { get; set; } + public decimal RationedSharesAmount { get; set; } /// /// 配股手数 /// [DisplayName("配股价")] - public double RationedSharesPrice { get; set; } + public decimal RationedSharesPrice { get; set; } /// /// 是否有效 /// public bool ValidStatus { get; set; } + + /// + /// Ownership of the record. Manual records always take precedence over imports. + /// + [DisplayName("数据来源"), Required, MaxLength(32)] + public string DataSource { get; set; } = ExDividendDataSources.Manual; + + /// + /// Last update timestamp supplied by the market-data provider. + /// + [DisplayName("来源更新时间")] + public DateTime? SourceUpdatedAt { get; set; } } public class ex_dividend_infoReq : BaseSearchReq diff --git a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs index 83a6007e..26f22bfa 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs @@ -39,14 +39,15 @@ 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 } equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod from bod in t_bod.DefaultIfEmpty() - join dividend in DbContext.ex_dividend_info.Where(O => O.ExDividendDate == settleDate && O.ValidStatus) - on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend - from dividend in t_dividend.DefaultIfEmpty() select new { eod, @@ -55,24 +56,31 @@ namespace YLErp.Modules.EodModule bod.Amount, bod.Cost, //bod.AveragePrice - }, - dividend + } }; 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); return datas.Select(data => { var eod = data.eod; var bod = data.bod; - if (data.dividend != null) + // 命中除权数据后仍沿用原有股票结算分支:只重算除权后的收盘价和数量, + // 并保留原 Pv 的正负方向。其他 TradeType 当前不进入该分支,避免扩大 + // 本次查询重构的业务范围。 + if (dividendDict.TryGetValue(eod.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") { var SettlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice); - SettlePrice = diviService.GetPrice(SettlePrice, data.dividend); - var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend); + SettlePrice = diviService.GetPrice(SettlePrice, dividend); + var amount = diviService.GetPositionAmount(data.eod.Amount, dividend); eod.Pv = eod.Pv > 0 ? Math.Abs(amount * SettlePrice) : -Math.Abs(amount * SettlePrice); } } @@ -104,14 +112,14 @@ 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 } equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod from bod in t_bod.DefaultIfEmpty() - join dividend in DbContext.ex_dividend_info.AsNoTracking().Where(O => O.ExDividendDate == settleDate && O.ValidStatus) - on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend - from dividend in t_dividend.DefaultIfEmpty() join risk in DbContext.Set().AsNoTracking().Where(n => n.ValueDate == settleDate && n.TradeId > 0) on new { eod.ValueDate, eod.TradeId } equals new { risk.ValueDate, risk.TradeId } into risk_t from risk in risk_t.DefaultIfEmpty() select new @@ -123,24 +131,28 @@ namespace YLErp.Modules.EodModule bod.Cost, //bod.AveragePrice }, - dividend, risk }; 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); return datas.Select(data => { var pos = data.eod; var bod = data.bod; - if (data.dividend != null) + // 风险对象的除权 Pv 重算规则与上一个重载保持一致,仅在股票交易类型下执行。 + if (dividendDict.TryGetValue(pos.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") { var settlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice); - settlePrice = diviService.GetPrice(settlePrice, data.dividend); - var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend); + settlePrice = diviService.GetPrice(settlePrice, dividend); + var amount = diviService.GetPositionAmount(data.eod.Amount, dividend); pos.Pv = pos.Pv > 0 ? Math.Abs(amount * settlePrice) : -Math.Abs(amount * settlePrice); } } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 7242618e..ce9a9714 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -28,7 +28,8 @@ namespace YLErp.Modules.TradeModule.DealModule public List Execute(DateTime settleDate, IEnumerable positions) { var result = new List(); - var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V); + var dict = GetExDividendQuery(settleDate) + .ToDictionary(K => K.UnderlyingId, V => V); foreach (var item in positions) { double cost = item.Cost, @@ -76,7 +77,8 @@ namespace YLErp.Modules.TradeModule.DealModule useSaveTrades = new List(); useSaveUndedrlyings = new List(); var result = new List(); - var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V); + var dict = GetExDividendQuery(settleDate) + .ToDictionary(K => K.UnderlyingId, V => V); var tradeIds = trades.Select(O => O.id); var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); foreach (var t in trades) @@ -713,9 +715,15 @@ namespace YLErp.Modules.TradeModule.DealModule { return 0; } - var ratio = overrideDividendRatio != null ? overrideDividendRatio.Value : GetRatio(info); - double? result = price / ratio; - return Math.Round(result ?? 0, 4, MidpointRounding.AwayFromZero); + var decimalRatio = overrideDividendRatio.HasValue + ? (decimal)overrideDividendRatio.Value + : GetRatioDecimal(info); + if (decimalRatio == 0) + { + return 0; + } + var result = (decimal)price / decimalRatio; + return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero); } /// @@ -725,10 +733,17 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetRatio(ex_dividend_info info) { - var dividendRate = valuedateBLL.SystemDate.DividendRate / 100; + return (double)GetRatioDecimal(info); + } + + private decimal GetRatioDecimal(ex_dividend_info info) + { + var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m; var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice); - var cDivdPrice = (closePrice * 10.0 - (info.GiveCashAmount * (1 - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / (10 + info.GiveShareAmount + info.RationedSharesAmount); - return closePrice / cDivdPrice; + var decimalClosePrice = (decimal)closePrice; + var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / + (10m + info.GiveShareAmount + info.RationedSharesAmount); + return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice; } /// @@ -751,18 +766,20 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { - double? result = amount * (1 + info.GiveShareAmount / 10.0); - return Math.Round(result ?? 0, 12); + var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); + return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } public IQueryable GetExDividendQuery(DateTime valueDate) { - return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == valueDate); + return DbContext.ex_dividend_info + .Where(O => O.ValidStatus && O.ExDividendDate == valueDate); } public IQueryable GetExDividendQuery(DateTime dateStart, DateTime dateEnd) { - return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd); + return DbContext.ex_dividend_info + .Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd); } public IEnumerable GetExDividends(DateTime valueDate, params int[] underlyingIds) @@ -772,7 +789,7 @@ namespace YLErp.Modules.TradeModule.DealModule { query = query.Where(n => underlyingIds.Contains(n.UnderlyingId)); } - return query.ToArray(); + return query; } public IQueryable GetExDividendInfos(string underlyingCode) @@ -797,17 +814,17 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException("请使用正确的模板上传"); } - var dict = new Dictionary(); + var dividendInfos = new List(); for (var i = 0; i < dt.Rows.Count; i++) { var info = new ex_dividend_info { UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(), ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue, - GiveCashAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, - GiveShareAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, - RationedSharesAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, - RationedSharesPrice = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, + GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, + GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, + RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, + RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, OptId = OptUser.UserId, OptName = OptUser.UserName, OptDate = DateTime.Now @@ -824,9 +841,9 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行股权登记日不正确"); } - dict[$"{info.ExDividendDate}{info.UnderlyingCode}"] = info; + dividendInfos.Add(info); } - if (!AddDividendInfos(dict.Values, out var errMsg)) + if (!AddDividendInfos(dividendInfos, out var errMsg)) { throw new ServiceException(errMsg); } @@ -841,48 +858,163 @@ namespace YLErp.Modules.TradeModule.DealModule return ""; } + 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) + && (excludedId <= 0 || O.id != excludedId)); + } + + private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + // 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”。 + // 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值, + // 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。 + // 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。 + if (source.GiveCashAmount != 0m) + { + target.GiveCashAmount = source.GiveCashAmount; + } + if (source.GiveShareAmount != 0m) + { + target.GiveShareAmount = source.GiveShareAmount; + } + if (source.RationedSharesAmount != 0m) + { + target.RationedSharesAmount = source.RationedSharesAmount; + } + if (source.RationedSharesPrice != 0m) + { + target.RationedSharesPrice = source.RationedSharesPrice; + } + } + public bool AddDividendInfos(IEnumerable infos, out string errMsg) { try { - var keys = infos.Select(O => $"{O.ExDividendDate?.ToString("yyyy-MM-dd")}{O.UnderlyingCode}"); - var ids = infos.Select(O => O.id).ToHashSet(); - - var data = from dividendDb in DbContext.ex_dividend_info.Where(O => keys.Contains(O.ExDividendDate + O.UnderlyingCode) && O.ValidStatus) - where !ids.Contains(dividendDb.id) - select dividendDb; - if (data.Any()) + var dividendInfos = infos?.ToList(); + if (dividendInfos == null || dividendInfos.Count == 0) { - var dd = data.Select(O => O.UnderlyingCode + "_" + O.ExDividendDate).ToArray(); - errMsg = string.Join(",", dd) + "已存在除息信息,请修改原数据"; + errMsg = "没有可保存的除权除息信息"; return false; } - var basketList = - DataCacheProvider.GetUnderlyingDataSource() - .AsQueryable().Where(O => O.IsBasket() && O.SubData != null) - .Select(O => new { O.UnderlyingCode, O.SubData }); - IEnumerable priceList = null; - foreach (var item in infos) + var preparedInfos = new List<(ex_dividend_info Item, underlying_manager Underlying, DateTime ExDividendDate)>(); + var preparedIndexes = new Dictionary<(int UnderlyingId, DateTime ExDividendDate), int>(); + var recordKeys = new Dictionary(); + foreach (var item in dividendInfos) { + if (item == null || string.IsNullOrWhiteSpace(item.UnderlyingCode)) + { + errMsg = "标的代码信息不存在"; + return false; + } + var underlying = underlying_managerBLL.GetByCode(item.UnderlyingCode); if (underlying == null) { errMsg = $"{item.UnderlyingCode} 标的信息不存在"; return false; } + if (!item.ExDividendDate.HasValue) + { + errMsg = "股权登记日信息不存在"; + return false; + } + + // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 + // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 + var exDividendDate = item.ExDividendDate.Value.Date; + var businessKey = (underlying.id, exDividendDate); + if (item.id > 0 + && recordKeys.TryGetValue(item.id, out var existingRecordKey) + && existingRecordKey != businessKey) + { + errMsg = "同一除权信息不能重复保存"; + return false; + } + item.UnderlyingId = underlying.id; - item.GiveCashAmount = item.GiveCashAmount.FormatValue(6); - item.RationedSharesAmount = item.RationedSharesAmount.FormatValue(6); - item.RationedSharesPrice = item.RationedSharesPrice.FormatValue(6); - item.GiveShareAmount = item.GiveShareAmount.FormatValue(6); - item.ValidStatus = true; - item.OptId = OptUser.UserId; - item.OptName = OptUser.UserName; - item.OptDate = DateTime.Now; - var dividend = item.id > 0 ? DbContext.ex_dividend_info.Where(O => O.id == item.id).FirstOrDefault() : null; + item.ExDividendDate = exDividendDate; + item.GiveCashAmount = OtcFormatHelper.FormatValue(item.GiveCashAmount, 6); + item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6); + 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) + { + errMsg = $"{item.UnderlyingCode} {exDividendDate:yyyy-MM-dd}除权信息不能合并不同记录"; + return false; + } + + MergeNonZeroDividendValues(preparedItem, item); + if (item.id > 0) + { + recordKeys[item.id] = businessKey; + } + continue; + } + + if (item.id > 0) + { + recordKeys[item.id] = businessKey; + } + preparedIndexes.Add(businessKey, preparedInfos.Count); + preparedInfos.Add((item, underlying, exDividendDate)); + } + + var basketList = + DataCacheProvider.GetUnderlyingDataSource() + .AsQueryable().Where(O => O.CommodityCode == "篮子标的" && O.SubData != null) + .Select(O => new { O.UnderlyingCode, O.SubData }); + IEnumerable priceList = null; + foreach (var prepared in preparedInfos) + { + 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); if (dividend == null) - { DbContext.ex_dividend_info.Add(item); } + { + if (item.id > 0) + { + errMsg = "未找到要修改的除权除息信息"; + return false; + } + item.DataSource = ExDividendDataSources.Manual; + item.SourceUpdatedAt = null; + item.ValidStatus = true; + item.OptId = OptUser.UserId; + item.OptName = OptUser.UserName; + item.OptDate = DateTime.Now; + DbContext.ex_dividend_info.Add(item); + } else { if (checkDividendInfoExecuteStatus(dividend)) @@ -890,28 +1022,37 @@ namespace YLErp.Modules.TradeModule.DealModule errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!"; return false; } + var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id); + if (conflictingDividend != null) + { + errMsg = $"{item.UnderlyingCode} {itemDate:yyyy-MM-dd}除权信息已存在,不能修改为该业务键"; + return false; + } + var sourceUpdatedAt = dividend.SourceUpdatedAt; 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; - dividend.ValidStatus = item.ValidStatus; - dividend.OptId = item.OptId; - dividend.OptName = item.OptName; - dividend.OptDate = item.OptDate; + // 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零 + // 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入 + // 把旧的派息/送股/配股信息误清零。 + MergeNonZeroDividendValues(dividend, item); + dividend.ValidStatus = true; + dividend.DataSource = ExDividendDataSources.Manual; + dividend.SourceUpdatedAt = sourceUpdatedAt; + dividend.OptId = OptUser.UserId; + dividend.OptName = OptUser.UserName; + dividend.OptDate = DateTime.Now; } if (!basketList.Any()) { continue; } - var codes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode); - if (!codes.Any()) + var basketCodes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode); + if (!basketCodes.Any()) { continue; } - var removePriceList = DbContext.eod_stock_price.Where(O => codes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate); + var removePriceList = DbContext.eod_stock_price.Where(O => basketCodes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate); if (!removePriceList.Any()) { continue; @@ -954,7 +1095,7 @@ namespace YLErp.Modules.TradeModule.DealModule return true; } //查询篮子标的对应交易是否执行过收盘操作; - var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.IsBasket() && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray(); + var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray(); tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate) join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus)) on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate } diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index 0783d33a..a366b8ae 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -98,6 +98,10 @@ namespace YLErp.Web.Controllers else { r.ValidStatus = false; + r.DataSource = ExDividendDataSources.Manual; + r.OptId = CurUser.UserId; + r.OptName = CurUser.UserName; + r.OptDate = DateTime.Now; yldb.SaveChanges(); return JsonSuccess("删除成功"); } diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index 02645196..8039367f 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -28,10 +28,13 @@ function saveInfo(dataId, rowId) { g_grid.jqGrid('saveRow', rowId, { successfunc: function (response) { - var msg = response.responseJSON.msg; + var result = response.responseJSON || {}; + var msg = result.msg || "保存失败"; main.message(msg); $("#systemTip").text(new Date().toLocaleString() + " " + msg); + if (!result.success) return false; g_grid.trigger('reloadGrid'); + return true; }, "url": "/ex_dividend_info/SaveDividend", "extraparam": data, @@ -150,4 +153,4 @@ $(function () { onPaging: onJqgridPaging }; g_grid = jQuery('#listGrid').jqGrid(obj); -}); \ No newline at end of file +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyinglist.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyinglist.js index a2554cd8..6e61fece 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyinglist.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyinglist.js @@ -164,7 +164,7 @@ $(function () { //设置除权 function SetDividEnd(cellValue, options, rowObject) { - if (rowObject.UnderlyingInstrumentType !== "Stock" || rowObject.CommodityCode === "篮子标的" || !g_dividend) return ""; + if (["Stock", "Fund"].indexOf(rowObject.UnderlyingInstrumentType) < 0 || rowObject.CommodityCode === "篮子标的" || !g_dividend) return ""; var imageHtml = ""; return imageHtml; }