From f7766fb72360e1805678042f6e1112fc8de9ca96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Tue, 25 Aug 2026 15:39:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(dividend):=20=E5=85=AC=E5=8F=B8=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 债券付息查询/BondPayment/BondPaymentList这个界面 不再查询公司行为除权表 - 公司行为事件的说明 格式化一下变为:调整前:名义本金:XXXX  期初标的价格:XXXX 持仓数量:XXXX 调整后:名义本金:XXXX  期初标的价格:XXXX 持仓数量:XXXX - 报错信息修改:当某个标的的除权除息数据仍有交易引用时 则修改标的公司行为数据时则提示“不可修改,有交易【交易编号】使用了该条除权除息数据”。 --- .../BondPaymentListQueryBoundaryTest.cs | 47 +++++++++++++++ .../DividendInfoEditGuardMessageTest.cs | 59 +++++++++++++++++++ .../CorporateActionEventLifecycleTest.cs | 4 +- .../Modules/EodModule/BondPaymentService.cs | 38 +----------- .../Modules/SwapModule/SwapEventService.cs | 38 ++---------- .../TradeModule/DealModule/DividendService.cs | 55 ++++++++++------- .../Controllers/ex_dividend_infoController.cs | 6 +- 7 files changed, 154 insertions(+), 93 deletions(-) create mode 100644 UnitTestProject/Modules/EodModule/BondPaymentListQueryBoundaryTest.cs create mode 100644 UnitTestProject/Modules/EodModule/DividendInfoEditGuardMessageTest.cs 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/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 fedf0c00..1deac603 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -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 {