diff --git a/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs new file mode 100644 index 00000000..9d09352f --- /dev/null +++ b/UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; + +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class BondPaymentListQueryBoundaryTest + { + [TestMethod] + public void SearchList_OnlyQueriesBondPaymentTable() + { + var source = ReadBondPaymentServiceSource(); + var searchList = ExtractMethod(source, "public SearchListResult SearchList", "public BondPayment SaveBondPayment"); + + Assert.IsFalse( + searchList.Contains("ex_dividend_info", StringComparison.Ordinal), + "债券付息列表只能查询 bond_payment_info,不能把公司行为除权表拼入展示结果。"); + StringAssert.Contains(searchList, "DbContext.bondPayment"); + } + + private static string ExtractMethod(string source, string startMarker, string endMarker) + { + var start = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}"); + var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal); + Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}"); + return source.Substring(start, end - start); + } + + private static string ReadBondPaymentServiceSource() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var path = Path.Combine(directory.FullName, "YLErpDAL", "Modules", "EodModule", "BondPaymentService.cs"); + if (File.Exists(path)) + { + return File.ReadAllText(path); + } + directory = directory.Parent; + } + + Assert.Fail("Could not locate BondPaymentService.cs from the test output directory."); + return string.Empty; + } + } +} diff --git a/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs b/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs new file mode 100644 index 00000000..d0a6e001 --- /dev/null +++ b/UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; + +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class DividendInfoEditGuardMessageTest + { + [TestMethod] + public void AddDividendInfos_ReportsTheReferencingTradeNumberWhenAnExecutedActionIsEdited() + { + var source = ReadDividendServiceSource(); + var addDividendInfos = ExtractMethod(source, "public bool AddDividendInfos", "public bool checkDividendInfoExecuteStatus"); + var controllerSource = ReadExDividendInfoControllerSource(); + var deleteDividend = ExtractMethod(controllerSource, "public JsonResult deleteDividend", "public JsonResult ImportDividendInfo"); + + Assert.IsTrue(addDividendInfos.Contains("不可修改,有交易【", StringComparison.Ordinal)); + Assert.IsTrue(addDividendInfos.Contains("使用了该条除权除息数据", StringComparison.Ordinal)); + Assert.IsTrue(deleteDividend.Contains("不可修改,有交易【", StringComparison.Ordinal)); + Assert.IsTrue(deleteDividend.Contains("使用了该条除权除息数据", StringComparison.Ordinal)); + } + + private static string ExtractMethod(string source, string startMarker, string endMarker) + { + var start = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.IsTrue(start >= 0, $"Could not find method: {startMarker}"); + var end = source.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal); + Assert.IsTrue(end >= 0, $"Could not find method end: {endMarker}"); + return source.Substring(start, end - start); + } + + private static string ReadDividendServiceSource() + { + return ReadSource("YLErpDAL", "Modules", "TradeModule", "DealModule", "DividendService.cs"); + } + + private static string ReadExDividendInfoControllerSource() + { + return ReadSource("YLErpWeb", "Controllers", "ex_dividend_infoController.cs"); + } + + private static string ReadSource(params string[] relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var path = Path.Combine(new[] { directory.FullName }.Concat(relativePath).ToArray()); + 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/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs index 7e202a38..5df141d4 100644 --- a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -37,8 +37,8 @@ namespace YLErp.Modules.SwapModule Assert.IsFalse(snapshot.Applied); var reason = SwapEventService.BuildCorporateActionEventReason(snapshot); - StringAssert.Contains(reason, "BeforeQuantity=1000"); - StringAssert.Contains(reason, "AfterQuantity=0"); + StringAssert.Contains(reason, "调整前:名义本金:100000 期初标的价格:100 持仓数量:1000"); + StringAssert.Contains(reason, "调整后:名义本金:0 期初标的价格:0 持仓数量:0"); } [TestMethod] diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 19366db5..5b6f03ce 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -59,43 +59,7 @@ namespace YLErp.Modules.EodModule create_time = source.create_time, update_time = source.update_time }; - // 不再依赖 bond-sync 镜像:Stock/Fund 公司行为直接作为展示行返回。 - // 展示金额按“每 10 份派现金额”换算为 GiveCashAmount / 10;EOD 计算仍使用 - // GetBondPayments 的内部单位口径,不受此处展示换算影响。 - var corporateQuery = from un in queryUn - join dividend in DbContext.ex_dividend_info.AsNoTracking() - on un.UnderlyingCode equals dividend.UnderlyingCode - where dividend.ValidStatus - && dividend.EffectiveDate.HasValue - && dividend.EffectiveDate.Value >= valueDtStart - && dividend.EffectiveDate.Value < valueDtEnd - && dividend.GiveCashAmount != 0 - && (string.IsNullOrEmpty(req.UnderlyingCode) - || dividend.UnderlyingCode.Contains(req.UnderlyingCode)) - && (un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock - || un.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) - && (string.IsNullOrEmpty(req.DataSource) - || "公司行为除权表".Contains(req.DataSource)) - select new BondPaymentDto - { - id = -dividend.id, - channel_source = "公司行为除权表", - MarketName = un.MarketName, - security_id = un.UnderlyingCode, - symbol = un.UnderlyingName, - coupon_rate = null, - payment_date = dividend.EffectiveDate, - payment_interest = dividend.GiveCashAmount / 10m, - payment_parvalue = null, - paying_price = dividend.GiveCashAmount / 10m, - create_time = dividend.OptDate, - update_time = dividend.OptDate - }; - // EF Core 无法翻译两个对 BondPaymentDto 继承属性赋值集合不完全一致的投影 - // 直接 Concat;分别执行后在内存合并,不改变两组查询的筛选口径。 - var rows = query.ToList(); - rows.AddRange(corporateQuery.ToList()); - var result = rows.AsQueryable().ToSearchList(req); + var result = query.ToSearchList(req); return result; } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index f9fb7204..544173f7 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -439,7 +439,7 @@ namespace YLErp.Modules.SwapModule /// /// 获取公司行为公式使用的收盘价。 /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 - /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日 + /// ExDividendDate;不能在 除权日 EOD 误取 除权日收盘价重算 登记日 /// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。 /// protected virtual decimal GetFundCorporateActionClosePrice( @@ -516,18 +516,22 @@ namespace YLErp.Modules.SwapModule // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List(); + // 除权日信息 var exDividendInfos = corporateActionInfos .Where(x => x != null && x.ValidStatus && x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date == settleDate.Date) .ToList(); + // 登记日信息 var registrationInfos = corporateActionInfos .Where(x => x != null && x.ValidStatus && x.ExDividendDate.HasValue && x.ExDividendDate.Value.Date == settleDate.Date) .ToList(); + + // 公司行为去重 - 除权日 var duplicateDividend = exDividendInfos .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(x => x.Count() > 1); @@ -535,7 +539,8 @@ namespace YLErp.Modules.SwapModule { throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); } - // 公司行为去重 - 拦截 + + // 公司行为去重 - 登记日 var duplicateRegistration = registrationInfos .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(x => x.Count() > 1); @@ -545,6 +550,8 @@ namespace YLErp.Modules.SwapModule // 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。 throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录"); } + + // 根据标的代码 创建map var exDividendByCode = exDividendInfos.ToDictionary( x => x.UnderlyingCode, x => x, @@ -588,21 +595,21 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 + // 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存 // 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为 // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 - // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 - // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 - - // 重置基线 + // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2, + // 否则会错误得到 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + // 重置基线 - 除权日 var openingEodPositions = PrepareFundOpeningEodPositions( - eodPositions, + eodPositions, // 上一日终持仓 exDividendByCode, settleDate); // 构建公司行为前eod持仓 var corporateActionBeforePositions = BuildCorporateActionBeforePositions( - eodPositions, + eodPositions, // 上一日终持仓 posiList); // 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。 @@ -613,18 +620,19 @@ namespace YLErp.Modules.SwapModule // 处理浮动腿归档 var curEodPosis = DealFloatPositions( - floatPositionsForCompose, - realPosiList, - openingEodPositions, - todyEodPositions, - settleDate, - td, - preSettleDate, - flowEvents); + floatPositionsForCompose, // 初始腿 + realPosiList, // 实时腿 + openingEodPositions, // 开盘基线 + todyEodPositions, // 当日终持仓 + settleDate, // 收盘日期 + td, // 交易 + preSettleDate, // 上一交易日 + flowEvents); // 流水事件 // 现金分红不在登记日直接累加;Copy/Update EOD 通过 CalcBondPayment // 读取 EffectiveDate 命中的 ex_dividend_info,并生成 TdPosiDividend。 // 这样登记日快照不提前变化,且公司行为分红与债券付息共用同一待实现余额。 + // 公司行为事件 RecordCorporateActionEvents( td, curEodPosis, @@ -632,8 +640,9 @@ namespace YLErp.Modules.SwapModule registrationInfos, exDividendInfos, settleDate); - // 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要 - // 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。 + // 登记日 EOD 仍保存除权前快照, + // 但下一交易日开盘读取的实时浮动腿需要先切换到生效后的 Q/P。 + // 该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。 UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); @@ -668,8 +677,8 @@ namespace YLErp.Modules.SwapModule /// /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。 /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 - /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; - /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 + /// 例如 1000 份/100 元、10 送 10 的记录在 登记日 EOD 仍是 1000/100; + /// 除权日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 /// protected List PrepareFundOpeningEodPositions( IReadOnlyCollection previousEodPositions, @@ -729,6 +738,7 @@ namespace YLErp.Modules.SwapModule var dividendTaxRate = 0m; foreach (var position in positions) { + // 不是浮动腿 或者 不是 Fund Stock类型的标的 或者 没有除权信息 或者 除权日不是结算日 - 跳过 if (position.PosiDirection <= 0 || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) || string.IsNullOrWhiteSpace(position.UnderlyingCode) @@ -739,7 +749,7 @@ namespace YLErp.Modules.SwapModule continue; } - // 获取除权参考价 + // 获取除权参考价 - 登记日收盘价 var corporateActionClosePrice = GetFundCorporateActionClosePrice( dividendInfo, position.UnderlyingPrice); @@ -773,8 +783,11 @@ namespace YLErp.Modules.SwapModule position.PosiNetFeePrice = adjusted.NetFeePrice; position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; + // 多空方向 var shortRatio = DirectionRatio.LongShort(position.PositionType); + // 收付方向 var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); + // 处理价格的正负号(收支方向) position.PosiNotionalValue = Math.Round( position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, ConsGlobal.MoneyRound, @@ -900,8 +913,9 @@ namespace YLErp.Modules.SwapModule return; } - // 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的 - // 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。 + // 登记日收盘后即切换实时 BOD。 + // EffectiveDate 只用于确认这条记录仍是未来生效的公司行为; + // 无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。 var pendingInfos = (registrationInfos ?? Array.Empty()) .Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date) .ToList(); @@ -912,6 +926,7 @@ namespace YLErp.Modules.SwapModule && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType) && !string.IsNullOrWhiteSpace(x.UnderlyingCode))) { + // 实时腿 var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial @@ -920,7 +935,8 @@ namespace YLErp.Modules.SwapModule { continue; } - + + // 对每条当日 EOD 浮动腿,按标的代码在 pendingInfos 中找匹配的公司行为。 var pending = pendingInfos.FirstOrDefault(x => string.Equals( x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); if (pending != null) @@ -966,6 +982,7 @@ namespace YLErp.Modules.SwapModule return; } + // 登记日信息合并除权日信息 var infos = (registrationInfos ?? Array.Empty()) .Concat(effectiveInfos ?? Array.Empty()) .Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode)) @@ -983,6 +1000,7 @@ namespace YLErp.Modules.SwapModule return; } + // 跟据交易id查当前交易关联事件 var existingEvents = FindCorporateActionEvents(td.id); foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0 && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType))) @@ -1004,16 +1022,18 @@ namespace YLErp.Modules.SwapModule && x.Data.ExDividendInfoId == info.id && x.Data.PositionId == current.PositionId) .ToList(); + // 寻找applied = false的(登记日记录的) var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied) ?? matchingEvents.FirstOrDefault(); - var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId); + var previous = previousPositions?.FirstOrDefault(x => x != null + && x.PositionId == current.PositionId); // 登记日 false 除权日 true var isEffective = info.EffectiveDate.HasValue && info.EffectiveDate.Value.Date <= settleDate.Date && effectiveInfos != null && effectiveInfos.Any(x => x.id == info.id); - // 如果没有匹配到事件或事件未生效,则创建新事件。 + // 如果没有匹配到事件或今天不是除权日 但找到的事件的applied=true(异常事件/重收盘),则创建新事件。 if (eventData == null || (!isEffective && eventData.Data.Applied)) { // 创建新事件 @@ -1184,6 +1204,7 @@ namespace YLErp.Modules.SwapModule decimal dividendTaxRate, int grossPriceRound) { + // 计算除权系数 - adjustCashDividendPrice = false (现金分红模式) var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, closePrice, @@ -3634,9 +3655,10 @@ namespace YLErp.Modules.SwapModule { item.FloatRateAbs = item.position.PosiNotionalValue == 0 ? 0 : item.InterestAmount / item.position.PosiNotionalValue; } - // 交易录入的债券类收益互换价格以小数保存,展示时转为百分比价格; - // 普通收益互换录入的是数量/原始数值,不做乘 100 转换。 - SetPosiPrice(item.position, item.StructureType == "普通债券类收益互换"); + // 是否 ×100 由标的资产类型决定(债券价格以小数保存,展示时转为百分比价格), + // 与存储层 GetStorageDeliveryPriceRound / GetSwapValuationPrice 的 IsBond 口径一致, + // 不依赖簿记结构类型 StructureType。 + SetPosiPrice(item.position); } return retListResult; } @@ -3681,10 +3703,10 @@ namespace YLErp.Modules.SwapModule position.SwapPositionValue = -position.SwapPositionValue; position.PosiDividendSum = -position.PosiDividendSum; } - private void SetPosiPrice(eod_swap_position position, bool? useBondPriceScale = null) + private void SetPosiPrice(eod_swap_position position) { var um = DataCacheProvider.GetUnderlyingDataSource().GetData(position.UnderlyingCode); - if (useBondPriceScale ?? (um != null && um.IsBond())) + if (um != null && um.IsBond()) { position.PosiNetPrice *= 100; position.UnderlyingPrice *= 100; diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index 711bc06f..935bda97 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -247,8 +247,8 @@ namespace YLErp.Modules.SwapModule } /// - /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、 - /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。 + /// 公司行为说明仅展示调整前后的名义本金、期初标的价格和持仓数量, + /// 便于操作历史直接比对持仓基线。 /// public static string BuildCorporateActionEventReason(CorporateActionEventData data) { @@ -257,37 +257,11 @@ namespace YLErp.Modules.SwapModule return "公司行为快照为空"; } - // 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。 + // 使用 InvariantCulture 固定小数格式,说明文本不随服务器区域设置变化。 string D(decimal value) => value.ToString(CultureInfo.InvariantCulture); - string Date(DateTime? value) => value.HasValue - ? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) - : ""; - - return string.Join("; ", new[] - { - $"公司行为[{data.UnderlyingCode}]", - $"ExDividendDate={Date(data.ExDividendDate)}", - $"EffectiveDate={Date(data.EffectiveDate)}", - $"ExDividendInfoId={data.ExDividendInfoId}", - $"PositionId={data.PositionId}", - $"GiveCashAmount={D(data.GiveCashAmount)}", - $"GiveShareAmount={D(data.GiveShareAmount)}", - $"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}", - $"RationedSharesAmount={D(data.RationedSharesAmount)}", - $"RationedSharesPrice={D(data.RationedSharesPrice)}", - "调整前", - $"BeforeNotional={D(data.BeforeNotional)}", - $"BeforePrice={D(data.BeforePrice)}", - $"BeforeQuantity={D(data.BeforeQuantity)}", - $"BeforePendingDividend={D(data.BeforePendingDividend)}", - "调整后", - $"AfterNotional={D(data.AfterNotional)}", - $"AfterPrice={D(data.AfterPrice)}", - $"AfterQuantity={D(data.AfterQuantity)}", - $"AfterPendingDividend={D(data.AfterPendingDividend)}", - $"CashFlowChange={D(data.CashFlowChange)}", - $"Applied={data.Applied}" - }); + return $"调整前:名义本金:{D(data.BeforeNotional)} 期初标的价格:{D(data.BeforePrice)} 持仓数量:{D(data.BeforeQuantity)}" + + Environment.NewLine + + $"调整后:名义本金:{D(data.AfterNotional)} 期初标的价格:{D(data.AfterPrice)} 持仓数量:{D(data.AfterQuantity)}"; } public void DeleteEvent(int tradeId) diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 8b026f5d..1deac603 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -773,9 +773,9 @@ namespace YLErp.Modules.TradeModule.DealModule bool adjustCashDividendPrice = true) { // 价格调整模式除权参考价 = - // 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价 - // - ----------------------------------------------------- - // (10 + 送股数 + 配股数) * 拆股倍数 + // 登记日收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价 + // --------------------------------------------------------------- + // (10 + 送股数 + 配股数) * 拆股倍数 // 场内链路默认继续把现金派息计入除权参考价; // TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。 var cashPriceAdjustment = adjustCashDividendPrice @@ -784,8 +784,8 @@ namespace YLErp.Modules.TradeModule.DealModule // 拆股倍数 var splitFactor = GetSplitFactor(info); // 除权参考价(TRS) : - // 收盘价 * 10 + 配股数 * 配股价 - // ------------------------------ + // 登记日收盘价 * 10 + 配股数 * 配股价 + // ------------------------------------- // (10 + 送股数 + 配股数) * 拆股倍数 var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment + info.RationedSharesAmount * info.RationedSharesPrice) @@ -1159,9 +1159,10 @@ namespace YLErp.Modules.TradeModule.DealModule } else { - if (checkDividendInfoExecuteStatus(dividend)) + var executingTradeNumber = GetDividendInfoExecutingTradeNumber(dividend); + if (!string.IsNullOrWhiteSpace(executingTradeNumber)) { - errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!"; + errMsg = $"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据"; return false; } var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id); @@ -1223,6 +1224,14 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// public bool checkDividendInfoExecuteStatus(ex_dividend_info info) + { + return !string.IsNullOrWhiteSpace(GetDividendInfoExecutingTradeNumber(info)); + } + + /// + /// 返回仍在引用已执行公司行为的交易编号;无引用时返回空字符串。 + /// + public string GetDividendInfoExecutingTradeNumber(ex_dividend_info info) { // TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定 // 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为 @@ -1230,34 +1239,37 @@ namespace YLErp.Modules.TradeModule.DealModule if (info?.EffectiveDate.HasValue == true) { var effectiveDate = info.EffectiveDate.Value.Date; - var trsTradeIds = DbContext.trade + var trsTrades = DbContext.trade .Where(x => x.ValidState != ConsGlobal.InValid && x.TradeType == "收益互换" && x.UnderlyingCode == info.UnderlyingCode && x.TradeDate <= effectiveDate && x.ExerciseDate >= effectiveDate) - .Select(x => x.id) + .Select(x => new { x.id, x.TradeNumber }) .ToList(); - if (trsTradeIds.Count > 0) + if (trsTrades.Count > 0) { // 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改 // 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前 // 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。 - var hasAppliedEod = DbContext.eod_swap_position.Any(x => + var trsTradeIds = trsTrades.Select(x => x.id).ToList(); + var appliedTradeId = DbContext.eod_swap_position.Where(x => trsTradeIds.Contains(x.SwapTradeId) && !x.Invalid && x.UnderlyingCode == info.UnderlyingCode - && x.ValueDate >= effectiveDate); - if (hasAppliedEod) + && x.ValueDate >= effectiveDate) + .Select(x => x.SwapTradeId) + .FirstOrDefault(); + if (appliedTradeId > 0) { - return true; + return trsTrades.First(x => x.id == appliedTradeId).TradeNumber; } // EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。 // 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的 // 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断, // 否则交易已回退仍会被错误判定为“已执行”而无法修改。 - return false; + return string.Empty; } } @@ -1268,20 +1280,22 @@ namespace YLErp.Modules.TradeModule.DealModule var tradeQuery = from t in DbContext.trade.Where(O => O.UnderlyingCode == info.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 } - select et.id; - if (tradeQuery.Any()) + select t.TradeNumber; + var executingTradeNumber = tradeQuery.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(executingTradeNumber)) { - return true; + return executingTradeNumber; } //查询篮子标的对应交易是否执行过收盘操作; 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 } - select et.id; - if (tradeQuery.Any()) + select t.TradeNumber; + executingTradeNumber = tradeQuery.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(executingTradeNumber)) { - return true; + return executingTradeNumber; } //查询多标的对应交易是否执行过收盘操作; tradeQuery = from ts in DbContext.trade_swap_detail.Where(O => O.UnderlyingCode == info.UnderlyingCode) @@ -1289,13 +1303,14 @@ namespace YLErp.Modules.TradeModule.DealModule on ts.TradeId equals t.id 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 } - select et.id; - if (tradeQuery.Any()) + select t.TradeNumber; + executingTradeNumber = tradeQuery.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(executingTradeNumber)) { - return true; + return executingTradeNumber; } } - return false; + return string.Empty; } public List QueryDividendTrade(DividendTradeReq req) diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index e8db3bb8..c731fc17 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -105,9 +105,11 @@ namespace YLErp.Web.Controllers { return JsonError("未找到有效的除权除息信息"); } - if (new DividendService(CurUser).checkDividendInfoExecuteStatus(r)) + var dividendService = new DividendService(CurUser); + var executingTradeNumber = dividendService.GetDividendInfoExecutingTradeNumber(r); + if (!string.IsNullOrWhiteSpace(executingTradeNumber)) { - return JsonError("该条除权信息已被执行,不允许删除!"); + return JsonError($"不可修改,有交易【{executingTradeNumber}】使用了该条除权除息数据"); } else { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index aeb53d83..fdfc7b01 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -46,7 +46,9 @@ const vue = new Vue({ } }, created() { - this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1; + // 是否 ×100 由浮动腿标的资产类型决定(债券价格以小数保存,展示时转为百分比), + // 与存储层 SetPosiPrice/GetStorageDeliveryPriceRound 的 IsBond 口径一致,不依赖簿记结构类型。 + this.multiplier = this.IsBond(swapInstrumentType) ? 100 : 1; this.initDeal(); this.setValueDate(); }, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 346174c4..72ff7698 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -64,7 +64,7 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, - // EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算 + // EQD-6953 浮动腿标的为债券时启用 期末交割全价↔结算收益率(ExitYtm) 互算 isBondTRS: false, // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 @@ -77,13 +77,15 @@ const vue = new Vue({ minStartDate() { return this.deal.StartDate; }, - // EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算 + // EQD-6953:浮动腿标的为债券时,才展示 源/AUTO/REV 标识并允许互算 isBondUnwindLeg() { return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType); } }, created() { - this.isBondTRS = this.deal.StructureType == '普通债券类收益互换'; + // 是否债券收益互换由浮动腿标的资产类型决定(×100 展示口径与存储层 SetPosiPrice 的 IsBond 一致), + // 不依赖簿记结构类型 StructureType。 + this.isBondTRS = this.IsBond(swapInstrumentType); this.multiplier = this.isBondTRS ? 100 : 1; this.initDeal(); this.setValueDate(this.deal.ValueDate);