From 5a51d6697001bff00fd4bcf253a4d24d98d982a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 13 Aug 2026 10:53:57 +0800 Subject: [PATCH 01/43] =?UTF-8?q?refactor(dividend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=99=A4=E6=9D=83=E9=99=A4=E6=81=AF=E8=AE=A1=E7=AE=97=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B9=B6=E6=94=B9=E8=BF=9B=E6=95=B0=E6=8D=AE=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E7=B2=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将除权除息相关数值字段从 double 类型改为 decimal 类型以提高精度 - 重构了红利比率计算方法 GetRatio,新增 GetRatioDecimal 方法使用 decimal 计算 - 修改价格和持仓数量计算逻辑,统一使用 decimal 进行高精度运算 - 更新数据库查询逻辑,将篮子标的判断从 IsBasket() 方法改为 CommodityCode 条件 - 优化 AddDividendInfos 方法中的批量处理逻辑,增加业务键冲突检测 - 添加数据源标识字段 DataSource 和来源更新时间字段 SourceUpdatedAt - 新增 FindExDividendByBusinessKey 和 MergeNonZeroDividendValues 辅助方法 - 更新结算服务中除权除息信息的获取方式,使用字典查找替代 LINQ JOIN - 修复前端保存除权信息时的响应处理逻辑 - 为基金类型也开放除权功能,不仅限于股票类型 - 添加单元测试验证篮子标的查询翻译逻辑的正确性 --- .../DividendBasketQueryTranslationTest.cs | 68 +++++ .../EodModule/EodSettlementTaskTest.cs | 10 +- YLErpDAL/Model/ExDividendInfo.cs | 26 +- .../SettlementModule/EodSettlementService.cs | 26 +- .../TradeModule/DealModule/DividendService.cs | 233 ++++++++++++++---- .../Controllers/ex_dividend_infoController.cs | 4 + .../app/underlying/underlyingDividendInfo.js | 7 +- .../Scripts/app/underlying/underlyinglist.js | 2 +- 8 files changed, 296 insertions(+), 80 deletions(-) create mode 100644 UnitTestProject/Modules/EodModule/DividendBasketQueryTranslationTest.cs 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..f87252f7 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs @@ -44,9 +44,6 @@ namespace YLErp.Modules.EodModule 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 +52,25 @@ namespace YLErp.Modules.EodModule bod.Amount, bod.Cost, //bod.AveragePrice - }, - dividend + } }; 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 eod = data.eod; var bod = data.bod; - if (data.dividend != null) + 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); } } @@ -109,9 +107,6 @@ namespace YLErp.Modules.EodModule 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 +118,25 @@ 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) + 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..033c8227 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,149 @@ namespace YLErp.Modules.TradeModule.DealModule return ""; } + private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0) + { + 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; + } + + 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; + // 批量保存除权信息时,检测同一业务键(标的+日期)下是否存在冲突的数据库记录。 + 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; + 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,6 +1008,13 @@ 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; @@ -897,21 +1022,23 @@ namespace YLErp.Modules.TradeModule.DealModule 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; + 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 +1081,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; } From 04f4468be5be403d86720b1137e77f31d2c83f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Thu, 13 Aug 2026 11:09:50 +0800 Subject: [PATCH 02/43] =?UTF-8?q?refactor(trade):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=99=A4=E6=9D=83=E4=BF=A1=E6=81=AF=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 FindExDividendByBusinessKey 方法中添加注释说明业务唯一键按"标的 + 自然日"定义 - 实现字段独立合并策略,当前值非零时覆盖旧值,为零时保留旧值 - 保存前统一截断时间部分,确保同一天不同时间能命中同一个自然日业务键 - 先在当前批次内按业务键归并,避免重复行生成多条数据库记录 - 除权数据查询时不使用 SQL 左连接,防止重复行扩增影响风险计算 - 使用不区分大小写的 UnderlyingCode 匹配,兼容代码大小写差异 - 除权记录不参与 SQL 左连接,先完成基础关联再内存查找唯一记录 - 风险对象的除权 Pv 重算规则与持仓处理保持一致,仅在股票交易类型下执行 --- .../SettlementModule/EodSettlementService.cs | 16 +++++++++++++ .../TradeModule/DealModule/DividendService.cs | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) 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; From 8afd449c40220fbd0df9e8468940a68995444ce6 Mon Sep 17 00:00:00 2001 From: tengyufan <1532636164@qq.com> Date: Thu, 13 Aug 2026 17:21:04 +0800 Subject: [PATCH 03/43] =?UTF-8?q?feat(ClientBlack):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E9=BB=91=E5=90=8D=E5=8D=95=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 黑名单新增、删除、批量导入、批量移出在配置审批节点后,审批通过才生效 - 新增黑名单待提交后可提交审批;仅首节点未审批时可撤回,进入后续节点禁止撤回 - 增加黑名单审批列表、审批弹窗、审批操作日志及状态筛选 - 支持审批配置中的“黑名单审批”流程及对应菜单、模块权限、操作权限 - 黑名单审批中禁止同名记录覆盖备注;已在黑名单中的同名导入会明确提示本次将修改备注 - 未配置审批节点时保持原有直接生效行为 - 增加黑名单审批状态机单测,39/39 通过 - 修正撤回全部失败时仍提示“撤回审批成功”的问题 --- .../YLErp.Core/DBModels/ClientBlackLog.cs | 32 ++ Framework/YLErp.Core/DBModels/Client_Black.cs | 27 ++ .../ClientBlackApprovalPolicyTests.cs | 121 +++++ YLErpDAL/DataBase/ClientDBContext.cs | 4 +- YLErpDAL/Model/ClientBlackApprovalQueryRes.cs | 20 + YLErpDAL/Model/ClientBlackAuditReq.cs | 18 + YLErpDAL/Model/clientblackReq.cs | 6 + .../ClientModule/ClientBlackApprovalPolicy.cs | 89 ++++ .../ClientModule/ClientBlackService.cs | 429 +++++++++++++++--- .../ClientModule/ClientImportService.cs | 6 +- .../ClientModule/ClientProcessLogService.cs | 2 +- .../ClientModule/ClientProcessService.cs | 2 +- .../Modules/ClientModule/ClientSaveService.cs | 2 +- .../SystemModule/ApprovalProcessService.cs | 12 + YLErpWeb/App_Data/FunctionRight.xml | 3 + YLErpWeb/App_Data/Menus.txt | 3 +- YLErpWeb/Common/UserInfoRight.cs | 6 + .../AccountOpeningProcessController.cs | 5 +- YLErpWeb/Controllers/clientController.cs | 4 +- YLErpWeb/Controllers/clientblackController.cs | 112 +++-- .../Views/AccountOpeningProcess/Index.cshtml | 40 ++ .../clientblack/clientblackApproval.cshtml | 74 +++ .../clientblack/clientblackLogList.cshtml | 14 + .../Views/clientblack/clientblackView.cshtml | 45 ++ .../Views/clientblack/clientblacklist.cshtml | 34 +- .../Scripts/app/system/Approvalprocess.js | 86 +++- 26 files changed, 1090 insertions(+), 106 deletions(-) create mode 100644 Framework/YLErp.Core/DBModels/ClientBlackLog.cs create mode 100644 UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs create mode 100644 YLErpDAL/Model/ClientBlackApprovalQueryRes.cs create mode 100644 YLErpDAL/Model/ClientBlackAuditReq.cs create mode 100644 YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs create mode 100644 YLErpWeb/Views/clientblack/clientblackApproval.cshtml create mode 100644 YLErpWeb/Views/clientblack/clientblackLogList.cshtml create mode 100644 YLErpWeb/Views/clientblack/clientblackView.cshtml diff --git a/Framework/YLErp.Core/DBModels/ClientBlackLog.cs b/Framework/YLErp.Core/DBModels/ClientBlackLog.cs new file mode 100644 index 00000000..54bcf093 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/ClientBlackLog.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + /// + /// 客户黑名单审批及操作日志。 + /// + [Table("client_blacklog")] + public class ClientBlackLog + { + public long id { get; set; } + + public int ClientBlackId { get; set; } + + public string Changes { get; set; } + + public string OptType { get; set; } + + public string DataType { get; set; } + + public int OptId { get; set; } + + public string OptName { get; set; } + + public DateTime OptDate { get; set; } + } + + [NotMapped] + public class ClientBlackLogDto : ClientBlackLog + { + } +} diff --git a/Framework/YLErp.Core/DBModels/Client_Black.cs b/Framework/YLErp.Core/DBModels/Client_Black.cs index 306d3cdd..7fb31970 100644 --- a/Framework/YLErp.Core/DBModels/Client_Black.cs +++ b/Framework/YLErp.Core/DBModels/Client_Black.cs @@ -10,6 +10,13 @@ namespace YLErp.Model [Table("client_black")] public class client_black : DBModelWithOperator, IDataEntity, IDataTraceV2, IClonable { + public const string 未提交 = "未提交"; + public const string 新增审批中 = "新增审批中"; + public const string 新增已拒绝 = "新增已拒绝"; + public const string 已加入 = "已加入"; + public const string 删除审批中 = "删除审批中"; + public const string 删除已拒绝 = "删除已拒绝"; + /// /// 客户名称 /// @@ -25,6 +32,26 @@ namespace YLErp.Model [DataChange] public string Remarks { get; set; } + [DisplayName("提交审批时间")] + public DateTime? ApprovalOptDate { get; set; } + + [DisplayName("提交审批人")] + public string ApprovalOptName { get; set; } + + public int ApprovalProcess { get; set; } + + [DisplayName("状态")] + public string State { get; set; } = ""; + + [DisplayName("创建人")] + public int? creator_id { get; set; } + + [DisplayName("创建人")] + public string creator_name { get; set; } + + [DisplayName("创建时间")] + public DateTime? creator_time { get; set; } + public client_black Clone() { return (client_black)MemberwiseClone(); diff --git a/UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs b/UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs new file mode 100644 index 00000000..47534e8f --- /dev/null +++ b/UnitTestProject/Modules/ClientModule/ClientBlackApprovalPolicyTests.cs @@ -0,0 +1,121 @@ +using YLErp.Model; + +namespace YLErp.Modules.ClientModule.Tests +{ + [TestClass] + public class ClientBlackApprovalPolicyTests + { + [DataTestMethod] + [DataRow(client_black.未提交, false)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.新增已拒绝, false)] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除审批中, true)] + [DataRow(client_black.删除已拒绝, true)] + public void IsEffective_OnlyAppliedOrPendingRemovalStatesAreEffective(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.IsEffective(state)); + } + + [DataTestMethod] + [DataRow(client_black.未提交, true)] + [DataRow(client_black.新增已拒绝, true)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.已加入, false)] + [DataRow(client_black.删除审批中, false)] + [DataRow(client_black.删除已拒绝, false)] + public void CanSubmitAddition_OnlyDraftOrRejectedAdditionCanSubmit(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanSubmitAddition(state)); + } + + [DataTestMethod] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除已拒绝, true)] + [DataRow(client_black.未提交, false)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.新增已拒绝, false)] + [DataRow(client_black.删除审批中, false)] + public void CanRequestRemoval_OnlyEffectiveNonPendingRemovalStatesCanRequest(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanRequestRemoval(state)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, 1, true)] + [DataRow(client_black.删除审批中, 1, true)] + [DataRow(client_black.新增审批中, 2, false)] + [DataRow(client_black.删除审批中, 2, false)] + [DataRow(client_black.未提交, 0, false)] + public void CanWithdraw_OnlyFirstApprovalNodeCanWithdraw(string state, int approvalProcess, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanWithdraw(state, approvalProcess)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.新增已拒绝)] + [DataRow(client_black.删除审批中, client_black.删除已拒绝)] + public void RejectedState_DistinguishesAdditionAndRemoval(string state, string expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.GetRejectedState(state)); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.未提交, 0)] + [DataRow(client_black.删除审批中, client_black.已加入, -2)] + public void WithdrawState_RestoresStateBeforeSubmission(string state, string expectedState, int expectedProcess) + { + var result = ClientBlackApprovalPolicy.GetWithdrawResult(state); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + } + + [DataTestMethod] + [DataRow(client_black.新增审批中, client_black.已加入, false)] + [DataRow(client_black.删除审批中, null, true)] + public void GetFinalResult_AdditionAppliesAndRemovalDeletes(string state, string expectedState, bool expectedDelete) + { + var result = ClientBlackApprovalPolicy.GetFinalResult(state); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedDelete, result.ShouldDelete); + } + + [DataTestMethod] + [DataRow(false, client_black.已加入, -2, true)] + [DataRow(true, client_black.未提交, 0, false)] + public void GetAdditionResult_OnlyEffectiveWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedEffective) + { + var result = ClientBlackApprovalPolicy.GetAdditionResult(hasApprovalProcess); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + Assert.AreEqual(expectedEffective, result.IsEffective); + } + + [DataTestMethod] + [DataRow(false, client_black.已加入, -2, true)] + [DataRow(true, client_black.删除审批中, 1, false)] + public void GetRemovalResult_OnlyDeletesImmediatelyWithoutApprovalProcess(bool hasApprovalProcess, string expectedState, int expectedProcess, bool expectedDelete) + { + var result = ClientBlackApprovalPolicy.GetRemovalResult(hasApprovalProcess); + + Assert.AreEqual(expectedState, result.State); + Assert.AreEqual(expectedProcess, result.ApprovalProcess); + Assert.AreEqual(expectedDelete, result.ShouldDelete); + } + + [DataTestMethod] + [DataRow(client_black.未提交, true)] + [DataRow(client_black.新增已拒绝, true)] + [DataRow(client_black.新增审批中, false)] + [DataRow(client_black.删除审批中, false)] + [DataRow(client_black.已加入, true)] + [DataRow(client_black.删除已拒绝, true)] + public void CanReplaceRemarks_ApprovalPendingRowsCannotBeOverwritten(string state, bool expected) + { + Assert.AreEqual(expected, ClientBlackApprovalPolicy.CanReplaceRemarks(state)); + } + } +} diff --git a/YLErpDAL/DataBase/ClientDBContext.cs b/YLErpDAL/DataBase/ClientDBContext.cs index c7098ab9..d210be40 100644 --- a/YLErpDAL/DataBase/ClientDBContext.cs +++ b/YLErpDAL/DataBase/ClientDBContext.cs @@ -19,6 +19,8 @@ namespace BaseOUDAL public DbSet client_black { get; set; } + public DbSet client_blacklog { get; set; } + public DbSet client_file { get; set; } public DbSet client_file_audit { get; set; } @@ -57,4 +59,4 @@ namespace BaseOUDAL public DbSet client_customer_manage { get; set; } } -} \ No newline at end of file +} diff --git a/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs b/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs new file mode 100644 index 00000000..92658cab --- /dev/null +++ b/YLErpDAL/Model/ClientBlackApprovalQueryRes.cs @@ -0,0 +1,20 @@ +namespace YLErp.Model +{ + public class ClientBlackApprovalQueryRes + { + public int id { get; set; } + public string EncryptId { get; set; } + public string ProcessStatus { get; set; } + public int ProcessOrderId { get; set; } + public string ProcessRoleName { get; set; } + public string ClientName { get; set; } + public int ProcessRoleId { get; set; } + public string Comments { get; set; } + public string ApprovalOptName { get; set; } + public DateTime? ApprovalOptDate { get; set; } + public string State { get; set; } + public int? creator_id { get; set; } + public string creator_name { get; set; } + public DateTime? creator_time { get; set; } + } +} diff --git a/YLErpDAL/Model/ClientBlackAuditReq.cs b/YLErpDAL/Model/ClientBlackAuditReq.cs new file mode 100644 index 00000000..7cf85003 --- /dev/null +++ b/YLErpDAL/Model/ClientBlackAuditReq.cs @@ -0,0 +1,18 @@ +using YLErp.Helpers; + +namespace YLErp.Model +{ + /// + /// 黑名单审批请求。 + /// + public class ClientBlackAuditReq + { + public string enid { get; set; } + + public int id => DataProtectHelper.DecryptInt(enid); + + public string status { get; set; } + + public string auditComment { get; set; } + } +} diff --git a/YLErpDAL/Model/clientblackReq.cs b/YLErpDAL/Model/clientblackReq.cs index 47cf73de..f7b8f958 100644 --- a/YLErpDAL/Model/clientblackReq.cs +++ b/YLErpDAL/Model/clientblackReq.cs @@ -14,6 +14,12 @@ namespace YLErp.Model /// public string Name { get; set; } + public DateTime? DateFromOptDate { get; set; } + + public DateTime? DateToOptDate { get; set; } + + public string ClientBlackStates { get; set; } + } } diff --git a/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs b/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs new file mode 100644 index 00000000..b880f82b --- /dev/null +++ b/YLErpDAL/Modules/ClientModule/ClientBlackApprovalPolicy.cs @@ -0,0 +1,89 @@ +using YLErp.Model; + +namespace YLErp.Modules.ClientModule +{ + public static class ClientBlackApprovalPolicy + { + public static readonly string[] EffectiveStates = + { + client_black.已加入, + client_black.删除审批中, + client_black.删除已拒绝 + }; + + public static bool IsEffective(string state) + { + return EffectiveStates.Contains(state); + } + + public static bool CanSubmitAddition(string state) + { + return state == client_black.未提交 || state == client_black.新增已拒绝; + } + + public static ClientBlackAdditionResult GetAdditionResult(bool hasApprovalProcess) + { + return hasApprovalProcess + ? new ClientBlackAdditionResult(client_black.未提交, 0, false) + : new ClientBlackAdditionResult(client_black.已加入, -2, true); + } + + public static bool CanRequestRemoval(string state) + { + return state == client_black.已加入 || state == client_black.删除已拒绝; + } + + public static ClientBlackRemovalResult GetRemovalResult(bool hasApprovalProcess) + { + return hasApprovalProcess + ? new ClientBlackRemovalResult(client_black.删除审批中, 1, false) + : new ClientBlackRemovalResult(client_black.已加入, -2, true); + } + + public static bool CanReplaceRemarks(string state) + { + return state != client_black.新增审批中 && state != client_black.删除审批中; + } + + public static bool CanWithdraw(string state, int approvalProcess) + { + return approvalProcess == 1 && + (state == client_black.新增审批中 || state == client_black.删除审批中); + } + + public static string GetRejectedState(string state) + { + return state switch + { + client_black.新增审批中 => client_black.新增已拒绝, + client_black.删除审批中 => client_black.删除已拒绝, + _ => throw new ArgumentException("当前状态不允许拒绝审批", nameof(state)) + }; + } + + public static ClientBlackWithdrawResult GetWithdrawResult(string state) + { + return state switch + { + client_black.新增审批中 => new ClientBlackWithdrawResult(client_black.未提交, 0), + client_black.删除审批中 => new ClientBlackWithdrawResult(client_black.已加入, -2), + _ => throw new ArgumentException("当前状态不允许撤回审批", nameof(state)) + }; + } + + public static ClientBlackFinalResult GetFinalResult(string state) + { + return state switch + { + client_black.新增审批中 => new ClientBlackFinalResult(client_black.已加入, false), + client_black.删除审批中 => new ClientBlackFinalResult(null, true), + _ => throw new ArgumentException("当前状态不允许完成审批", nameof(state)) + }; + } + } + + public readonly record struct ClientBlackWithdrawResult(string State, int ApprovalProcess); + public readonly record struct ClientBlackFinalResult(string State, bool ShouldDelete); + public readonly record struct ClientBlackAdditionResult(string State, int ApprovalProcess, bool IsEffective); + public readonly record struct ClientBlackRemovalResult(string State, int ApprovalProcess, bool ShouldDelete); +} diff --git a/YLErpDAL/Modules/ClientModule/ClientBlackService.cs b/YLErpDAL/Modules/ClientModule/ClientBlackService.cs index 894dec2a..edd40593 100644 --- a/YLErpDAL/Modules/ClientModule/ClientBlackService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientBlackService.cs @@ -41,6 +41,19 @@ namespace YLErp.Modules.ClientModule { predicate = predicate.And(d => d.Name.Contains(req.Name)); } + if (!string.IsNullOrEmpty(req.ClientBlackStates)) + { + var states = req.ClientBlackStates.Split(',', StringSplitOptions.RemoveEmptyEntries); + predicate = predicate.And(d => states.Contains(d.State)); + } + if (req.DateFromOptDate.HasValue) + { + predicate = predicate.And(d => d.OptDate >= req.DateFromOptDate.Value); + } + if (req.DateToOptDate.HasValue) + { + predicate = predicate.And(d => d.OptDate < req.DateToOptDate.Value.AddDays(1)); + } } var query = DbContext.client_black.AsNoTracking().Where(predicate); @@ -93,6 +106,314 @@ namespace YLErp.Modules.ClientModule return retListResult; } + public List ProcessList() + { + return DbContextFactory.GetYLDbContext().approvalprocess + .Where(s => s.processType == "ClientBlackProcess") + .OrderBy(s => s.order) + .ToList(); + } + + public void DeleteClientBlack(IEnumerable ids) + { + var idList = ids?.Distinct().ToList() ?? new List(); + if (idList.Count == 0) + { + throw new ServiceException("请选择要移出的黑名单客户"); + } + + var rows = DbContext.client_black.Where(x => idList.Contains(x.id)).ToList(); + if (rows.Count != idList.Count) + { + throw new ServiceException("未找到要删除的数据"); + } + + var hasProcess = ProcessList().Any(); + foreach (var row in rows) + { + if (!ClientBlackApprovalPolicy.CanRequestRemoval(row.State)) + { + throw new ServiceException($"黑名单客户{row.Name}当前状态不允许移出"); + } + + if (hasProcess) + { + var result = ClientBlackApprovalPolicy.GetRemovalResult(true); + row.State = result.State; + row.ApprovalProcess = result.ApprovalProcess; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, client_black.删除审批中); + } + else + { + RemoveEffectiveBlack(row); + } + } + + DbContext.SaveChanges(); + } + + public void WithdrawApprovalClientBlack(List ids, out int withdrawCount, out string msg) + { + withdrawCount = 0; + msg = ""; + var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList(); + foreach (var row in rows) + { + if (!ClientBlackApprovalPolicy.CanWithdraw(row.State, row.ApprovalProcess)) + { + if (row.ApprovalProcess > 1) + { + msg += row.Name + ","; + } + continue; + } + + var result = ClientBlackApprovalPolicy.GetWithdrawResult(row.State); + row.State = result.State; + row.ApprovalProcess = result.ApprovalProcess; + row.ApprovalOptName = null; + row.ApprovalOptDate = null; + ClientBlackCategoryLog(row.id, row.State); + withdrawCount++; + } + DbContext.SaveChanges(); + } + + public void SubmitApprovalClientBlack(List ids) + { + var rows = DbContext.client_black.Where(x => ids.Contains(x.id)).ToList(); + var process = ProcessList(); + foreach (var row in rows) + { + if (!ClientBlackApprovalPolicy.CanSubmitAddition(row.State)) + { + continue; + } + + if (process.Count == 0) + { + row.State = client_black.已加入; + row.ApprovalProcess = -2; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + var notifications = new List<(Client oldClient, Client newClient)>(); + ApplyEffectiveAddition(row.Name, notifications); + ClientBlackCategoryLog(row.id, client_black.已加入, "未设置审批流程,直接通过"); + DbContext.SaveChanges(); + SendClientNotifications(notifications); + continue; + } + + row.State = client_black.新增审批中; + row.ApprovalProcess = 1; + row.ApprovalOptName = UserName; + row.ApprovalOptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, client_black.新增审批中); + } + DbContext.SaveChanges(); + } + + public string AuditClientBlack(ClientBlackAuditReq req, bool isBatch = false, string optType = "") + { + var row = DbContext.client_black.Find(req.id); + if (row == null) + { + throw new ServiceException("审批失败,系统中没有该黑名单记录"); + } + if (row.State != client_black.新增审批中 && row.State != client_black.删除审批中) + { + throw new ServiceException("当前黑名单不在审批中"); + } + + var process = ProcessList(); + var currentNode = process.FirstOrDefault(x => x.order == row.ApprovalProcess); + if (currentNode == null || !UserBLL.GetRolesByUserId(UserId).Any(x => x.Id == currentNode.roleId)) + { + throw new ServiceException("当前用户无权审批该节点"); + } + if (req.status == "reject") + { + row.State = ClientBlackApprovalPolicy.GetRejectedState(row.State); + row.ApprovalProcess = -1; + row.ApprovalOptDate = DateTime.Now; + row.OptId = UserId; + row.OptName = UserName; + row.OptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, row.State, req.auditComment); + DbContext.SaveChanges(); + return "提交成功"; + } + + if (req.status != "pass") + { + throw new ServiceException("status参数不支持:" + req.status); + } + + var nextNode = process.FirstOrDefault(x => x.order > row.ApprovalProcess); + if (nextNode != null) + { + row.ApprovalProcess = nextNode.order; + row.ApprovalOptDate = DateTime.Now; + row.OptId = UserId; + row.OptName = UserName; + row.OptDate = DateTime.Now; + ClientBlackCategoryLog(row.id, row.State, req.auditComment); + DbContext.SaveChanges(); + return "提交成功"; + } + + var final = ClientBlackApprovalPolicy.GetFinalResult(row.State); + if (final.ShouldDelete) + { + RemoveEffectiveBlack(row, req.auditComment, isBatch ? optType : null); + DbContext.SaveChanges(); + } + else + { + row.State = final.State; + row.ApprovalProcess = -2; + row.ApprovalOptDate = DateTime.Now; + var notifications = new List<(Client oldClient, Client newClient)>(); + ApplyEffectiveAddition(row.Name, notifications); + ClientBlackCategoryLog(row.id, isBatch ? optType : row.State, req.auditComment); + DbContext.SaveChanges(); + SendClientNotifications(notifications); + } + return "提交成功"; + } + + public SearchListResult ClientBlackApprovalQuery(ClientBlackReq req) + { + var process = ProcessList(); + var predicate = PredicateBuilder.Create(x => x.ApprovalProcess > 0); + if (!string.IsNullOrWhiteSpace(req.Name)) + { + predicate = predicate.And(x => x.Name.Contains(req.Name)); + } + var query = from row in DbContext.client_black.AsNoTracking().Where(predicate) + select new ClientBlackApprovalQueryRes + { + id = row.id, + EncryptId = row.EncryptId, + ProcessOrderId = row.ApprovalProcess, + ProcessRoleId = 0, + ProcessStatus = "审批中 流程" + (row.ApprovalProcess - 1) + "/" + process.Count, + State = row.State, + ClientName = row.Name, + Comments = row.Remarks, + ApprovalOptName = row.ApprovalOptName, + ApprovalOptDate = row.ApprovalOptDate, + creator_id = row.creator_id, + creator_name = row.creator_name, + creator_time = row.creator_time + }; + if (string.IsNullOrEmpty(req.sidx)) + { + req.sidx = "ApprovalOptDate"; + req.sord = "desc"; + } + var result = query.OrderByDescending(x => x.ApprovalOptDate).ToSearchList(req); + var roles = new ErpBaseContext().Roles + .Select(x => new { x.Id, x.Name }) + .ToDictionary(x => x.Id, x => x.Name); + foreach (var item in result.rows) + { + var node = process.FirstOrDefault(x => x.order == item.ProcessOrderId); + if (node == null) + { + continue; + } + + item.ProcessRoleId = node.roleId; + item.ProcessRoleName = roles.TryGetValue(node.roleId, out var roleName) ? roleName : string.Empty; + } + return result; + } + + private void ApplyEffectiveAddition(string name, List<(Client oldClient, Client newClient)> notifications) + { + var client = DbContext.client.FirstOrDefault(c => c.Name == name); + if (client == null) + { + return; + } + var dt = DateTime.Now; + var oldClient = client.Clone(); + if (client.ProcessStatus == "已开户") + { + client.ProcessOrderId = -4; + client.ProcessStatus = "已休眠"; + client.OptId = UserId; + client.OptName = UserName; + client.OptDate = dt; + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "休眠", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = dt + }); + notifications.Add((oldClient, client)); + } + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "加入黑名单", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = dt + }); + } + + private void RemoveEffectiveBlack(client_black row, string changes = null, string optType = null) + { + var client = DbContext.client.FirstOrDefault(c => c.Name == row.Name); + if (client != null) + { + DbContext.ClientAuditLog.Add(new ClientAuditLog + { + ClientId = client.id, + OptType = "移除黑名单", + Changes = string.Empty, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = DateTime.Now + }); + } + DbContext.client_black.Remove(row); + ClientBlackCategoryLog(row.id, optType ?? "已删除", changes); + } + + private void SendClientNotifications(List<(Client oldClient, Client newClient)> notifications) + { + foreach (var (oldClient, newClient) in notifications) + { + new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient); + } + } + + public void ClientBlackCategoryLog(int clientblackId, string optType, string changes = null) + { + DbContext.client_blacklog.Add(new ClientBlackLog + { + ClientBlackId = clientblackId, + OptType = optType, + Changes = changes, + DataType = "00", + OptId = UserId, + OptName = UserName, + OptDate = DateTime.Now + }); + } + /// /// 客户黑名单导入 /// @@ -165,37 +486,47 @@ namespace YLErp.Modules.ClientModule public void AddClientBlack(IEnumerable list, bool checkStatus) { + var inputList = list?.ToList() ?? new List(); var errMsgList = new List(); - var nameList = list.Select(O => O.Name); - var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)); + var nameList = inputList.Select(O => O.Name).ToList(); + var dbList = DbContext.client_black.Where(O => nameList.Contains(O.Name)).ToList(); + foreach (var item in dbList) + { + var obj = inputList.FirstOrDefault(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); + if (obj == null) + { + continue; + } + if (!ClientBlackApprovalPolicy.CanReplaceRemarks(item.State)) + { + throw new ServiceException("黑名单客户在审批中无法修改!"); + } + if (checkStatus && !string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks) + { + errMsgList.Add($"{item.Name}"); + } + } if (checkStatus) { - foreach (var item in dbList) - { - var obj = list.First(O => O.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(item.Remarks) && item.Remarks != obj.Remarks) - { - errMsgList.Add($"{item.Name}"); - continue; - } - } if (errMsgList.Count > 0) { var msg = ""; if (errMsgList.Count <= 5) { - msg = $"客户:{string.Join(",", errMsgList)},备注已存在,是否替换?"; + msg = $"客户:{string.Join(",", errMsgList)}当前已在黑名单中,本次将修改备注,备注已存在,是否确认?"; } else { - msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户,备注已存在,是否替换?"; + msg = $"{string.Join(",", errMsgList.Take(5))} 等{errMsgList.Count}个客户当前已在黑名单中,本次将修改备注,备注已存在,是否确认?"; } throw new ServiceException(msg); } } // 在外部定义列表来保存需要通知的客户对 var clientsToNotify = new List<(Client oldClient, Client newClient)>(); - foreach (var item in list) + var newItems = new List(); + var processList = ProcessList(); + foreach (var item in inputList) { if (string.IsNullOrWhiteSpace(item.Name)) { @@ -206,61 +537,45 @@ namespace YLErp.Modules.ClientModule item.OptId = UserId; item.OptName = UserName; item.OptDate = DateTime.Now; - var clientexistence = DbContext.client.FirstOrDefault(c => c.Name == item.Name); - if (clientexistence != null) + var existing = dbList.FirstOrDefault(x => x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); + if (existing != null) { - var dt = DateTime.Now; - if (clientexistence.ProcessStatus == "已开户") + var oldRemarks = existing.Remarks; + existing.Remarks = item.Remarks; + existing.OptId = UserId; + existing.OptName = UserName; + existing.OptDate = DateTime.Now; + if (oldRemarks != existing.Remarks) { - var oldClient= clientexistence.Clone(); - clientexistence.ProcessOrderId = -4; - clientexistence.ProcessStatus = "已休眠"; - clientexistence.OptId = UserId; - clientexistence.OptName = UserName; - clientexistence.OptDate = dt; - - DbContext.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clientexistence.id, - OptType = "休眠", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = dt - }); - // 如果原有状态是已开户,添加到通知列表 - if (oldClient != null) - { - clientsToNotify.Add((oldClient, clientexistence)); - } + ClientBlackCategoryLog(existing.id, "修改备注", $"备注:{oldRemarks ?? string.Empty} -> {existing.Remarks ?? string.Empty}"); } - ///日志记录 - DbContext.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clientexistence.id, - OptType = "加入黑名单", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = dt - }); + continue; } + var additionResult = ClientBlackApprovalPolicy.GetAdditionResult(processList.Any()); + item.State = additionResult.State; + item.ApprovalProcess = additionResult.ApprovalProcess; + item.creator_id = UserId; + item.creator_name = UserName; + item.creator_time = DateTime.Now; + if (additionResult.IsEffective) + { + ApplyEffectiveAddition(item.Name, clientsToNotify); + } + newItems.Add(item); } - if (dbList.Any()) + DbContext.client_black.AddRange(newItems); + DbContext.SaveChanges(); + foreach (var item in newItems) { - DbContext.client_black.RemoveRange(dbList); - DbContext.SaveChanges(); + ClientBlackCategoryLog(item.id, item.State); } - DbContext.client_black.AddRange(list); DbContext.SaveChanges(); // 发送Kafka消息 foreach (var (oldClient, newClient) in clientsToNotify) { new ClientKafkaService(_kafkaProduce).Send(newClient, oldClient); } - var importHasTagClientNames = list.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList(); + var importHasTagClientNames = inputList.Where(p => p.Tags != null && p.Tags.Count > 0).Select(p => p.Name).Distinct().ToList(); if (importHasTagClientNames != null && importHasTagClientNames.Count > 0) { var dbClients = DbContext.client.AsNoTracking().Where(p => importHasTagClientNames.Contains(p.Name)).Select(p => new ClientSimpleDto @@ -273,7 +588,7 @@ namespace YLErp.Modules.ClientModule var tagService = new TagService(OptUser); dbClients.ForEach(p => { - var importInfo = list.FirstOrDefault(d => d.Name.Equals(p.Name)); + var importInfo = inputList.FirstOrDefault(d => d.Name.Equals(p.Name)); if (importInfo != null) { tagService.SetClientTagForClientImport(new TagModule.Dto.SetClientTagForClientEditRequest { ClientId = p.id, Tags = importInfo.Tags }); diff --git a/YLErpDAL/Modules/ClientModule/ClientImportService.cs b/YLErpDAL/Modules/ClientModule/ClientImportService.cs index 4914a9c2..a59cf971 100644 --- a/YLErpDAL/Modules/ClientModule/ClientImportService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientImportService.cs @@ -317,7 +317,7 @@ namespace YLErp.Modules.ClientModule { return "第" + rowNum + "行客户类别,机构属性,客户性质关联性质有误,导入失败"; } - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return $"客户'{Name}'已经存在于黑名单中”"; } @@ -1070,7 +1070,7 @@ namespace YLErp.Modules.ClientModule } } } - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return "" + Name + "客户已经存在于黑名单中”"; } @@ -1733,7 +1733,7 @@ namespace YLErp.Modules.ClientModule //默认为1 IsReceiveEmail = 1; - if (DbContext.client_black.Any(c => c.Name == Name)) + if (DbContext.client_black.Any(c => c.Name == Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { return "" + Name + "客户已经存在于黑名单中"; } diff --git a/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs b/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs index d39d0a1e..68dc2e69 100644 --- a/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientProcessLogService.cs @@ -130,7 +130,7 @@ namespace YLErp.Modules.ClientModule try { - if (DbContext.client_black.Any(c => c.Name == client.Name)) + if (DbContext.client_black.Any(c => c.Name == client.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { client.RejectOrderId = client.ApprovalOrderId; client.ApprovalOrderId = -1; diff --git a/YLErpDAL/Modules/ClientModule/ClientProcessService.cs b/YLErpDAL/Modules/ClientModule/ClientProcessService.cs index 414d2b08..ba2554e3 100644 --- a/YLErpDAL/Modules/ClientModule/ClientProcessService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientProcessService.cs @@ -158,7 +158,7 @@ namespace YLErp.Modules.ClientModule throw new ServiceException("客户名称 必须填写"); } - if (DbContext.client_black.Any(c => c.Name == req.Name)) + if (DbContext.client_black.Any(c => c.Name == req.Name && ClientBlackApprovalPolicy.EffectiveStates.Contains(c.State))) { throw new ServiceException("该客户为黑名单客户,无法进行下一步操作"); } diff --git a/YLErpDAL/Modules/ClientModule/ClientSaveService.cs b/YLErpDAL/Modules/ClientModule/ClientSaveService.cs index b56b4083..358160d1 100644 --- a/YLErpDAL/Modules/ClientModule/ClientSaveService.cs +++ b/YLErpDAL/Modules/ClientModule/ClientSaveService.cs @@ -703,7 +703,7 @@ namespace YLErp.Modules.ClientModule //新增时,新的客户名如果在黑名单里,不允许新增 //修改时,旧的客户名如果在黑名单里,不允许修改 - if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck)) + if (!isAddNew && !req.Name.Equals(blackNameForCheck) && DbContext.client_black.Any(x => x.Name == blackNameForCheck && ClientBlackApprovalPolicy.EffectiveStates.Contains(x.State))) { throw new ServiceException("该客户为黑名单客户," + (req.id > 0 ? "不允许修改客户名称" : "不允许新增")); } diff --git a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs index ae140dd0..4e9be366 100644 --- a/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs +++ b/YLErpDAL/Modules/SystemModule/ApprovalProcessService.cs @@ -7,6 +7,7 @@ using System.Linq.Expressions; using YLErp.BLL.Eod; using YLErp.DBModels; using YLErp.Helpers; +using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.TradeModule; @@ -28,6 +29,17 @@ namespace YLErp.Modules.SystemModule { var clientQuery = DataCacheProvider.GetClientDataSource().AsQueryable(); + if (type == "ClientBlackProcess") + { + var clientdb = DbContextFactory.GetClientDbContext(OptUser); + if (clientdb.client_black.Any(x => x.State == client_black.新增审批中 || x.State == client_black.删除审批中)) + { + throw new ServiceException(data == null || data.Count == 0 + ? "有黑名单在审批中,不能删除审批流程!" + : "有黑名单在审批中,不能修改审批流程!"); + } + } + var delList = DbContext.approvalprocess.Where(s => s.processType == type).ToArray(); DbContext.approvalprocess.RemoveRange(delList); diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index eccba1c4..a19f257c 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -129,8 +129,11 @@ + + + diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index 506f2a79..5ab7f56e 100644 --- a/YLErpWeb/App_Data/Menus.txt +++ b/YLErpWeb/App_Data/Menus.txt @@ -63,6 +63,7 @@ {Name:"客户列表",Rights:["客户管理-客户查看"],Url:"client/ClientList"}, {Name:"客户审批",Rights:["客户管理-客户审批"],Url:"clientApproval/openingclientList"}, {Name:"黑名单客户",Rights:["客户管理-黑名单客户"],Url:"clientblack/clientblacklist"}, + {Name:"黑名单审批",Rights:["客户管理-黑名单审批"],Url:"clientblack/clientblackApproval"}, {Name:"授信管理",Rights:["客户管理-授信管理"],Url:"credit/creditList"}, {Name:"资信评级",Rights:["客户管理-资信评级"],Url:"client_rating/List"}, {Name:"机构账号设置",Rights:["客户管理-机构账号设置"],Url:"v3/client/account"} @@ -107,4 +108,4 @@ {Name:"做市账户",Rights:["系统管理-做市账户"],Url:"TrsAccountManage/Index"} ] } -] \ No newline at end of file +] diff --git a/YLErpWeb/Common/UserInfoRight.cs b/YLErpWeb/Common/UserInfoRight.cs index cf0989bd..30bc8b5a 100644 --- a/YLErpWeb/Common/UserInfoRight.cs +++ b/YLErpWeb/Common/UserInfoRight.cs @@ -251,6 +251,12 @@ namespace YLErp.Web /// public bool 黑名单客户管理 => _user.HasRight("客户管理-黑名单客户管理"); + public bool 黑名单审批 => _user.HasRight("客户管理-黑名单审批"); + + public bool 黑名单客户提交审批 => _user.HasRight("客户管理-黑名单客户提交审批"); + + public bool 黑名单客户撤回提交审批 => _user.HasRight("客户管理-黑名单客户撤回提交审批"); + /// /// 客户管理-黑名单客户 /// diff --git a/YLErpWeb/Controllers/AccountOpeningProcessController.cs b/YLErpWeb/Controllers/AccountOpeningProcessController.cs index 9d45614a..0b2af318 100644 --- a/YLErpWeb/Controllers/AccountOpeningProcessController.cs +++ b/YLErpWeb/Controllers/AccountOpeningProcessController.cs @@ -93,7 +93,8 @@ namespace YLErp.Web.Controllers var creditProcess = list.Where(s => s.processType == "CreditProcess").OrderBy(s => s.order).ToList(); var outCashProcess = list.Where(s => s.processType == "OutCashProcess").OrderBy(s => s.order).ToList(); var clientProcess = list.Where(s => s.processType == "ClientProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); - return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess }); + var clientBlackProcess = list.Where(s => s.processType == "ClientBlackProcess").OrderBy(s => s.order).ThenBy(s => s.parentNode).ThenBy(s => s.node).ToList(); + return Json(new { OpenProcess = openProcess, TradeProcess = tradeProcess, CloseProcess = closeProcess, CreditProcess = creditProcess, OutCashProcess= outCashProcess,ClientProcess = clientProcess, ClientBlackProcess = clientBlackProcess }); } @@ -232,4 +233,4 @@ namespace YLErp.Web.Controllers return Json(sList); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/clientController.cs b/YLErpWeb/Controllers/clientController.cs index 93f8c645..f1aaae6d 100644 --- a/YLErpWeb/Controllers/clientController.cs +++ b/YLErpWeb/Controllers/clientController.cs @@ -2529,7 +2529,7 @@ namespace YLErp.Web.Controllers return JsonError(error); } var clientblack = clientDB.client_black.FirstOrDefault(c => c.Name == client.Name); - if (clientblack != null) + if (clientblack != null && YLErp.Modules.ClientModule.ClientBlackApprovalPolicy.IsEffective(clientblack.State)) { return JsonError("该客户为黑名单客户,禁止取消休眠"); } @@ -3402,4 +3402,4 @@ namespace YLErp.Web.Controllers return JsonSuccess(); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/clientblackController.cs b/YLErpWeb/Controllers/clientblackController.cs index fc912195..9fe1d7ae 100644 --- a/YLErpWeb/Controllers/clientblackController.cs +++ b/YLErpWeb/Controllers/clientblackController.cs @@ -5,6 +5,19 @@ namespace YLErp.Web.Controllers { public class clientblackController : BaseController { + public static List GetClientBlackStates() + { + return new List + { + new() { Text = client_black.未提交, Value = client_black.未提交 }, + new() { Text = client_black.新增审批中, Value = client_black.新增审批中 }, + new() { Text = client_black.新增已拒绝, Value = client_black.新增已拒绝 }, + new() { Text = client_black.已加入, Value = client_black.已加入 }, + new() { Text = client_black.删除审批中, Value = client_black.删除审批中 }, + new() { Text = client_black.删除已拒绝, Value = client_black.删除已拒绝 } + }; + } + [MyAuthorize("客户管理-黑名单客户")] public ActionResult clientblacklist() { @@ -55,38 +68,75 @@ namespace YLErp.Web.Controllers } public ActionResult DeleteClientBlack(string ids) { - var datalist = ids.Split(','); - var list = new List(); - foreach (var item in datalist) + try { - var data = clientDB.client_black.Find(int.Parse(item)); - if (data == null) - { - return JsonError("未找到要删除的数据"); - } - else - { - var clitid = clientDB.client.Where(c => c.Name == data.Name).FirstOrDefault(); - if (clitid != null) - { - clientDB.ClientAuditLog.Add(new ClientAuditLog - { - ClientId = clitid.id, - OptType = "移除黑名单", - Changes = string.Empty, - DataType = "00", - OptId = UserId, - OptName = UserName, - OptDate = DateTime.Now - }); - } - - clientDB.client_black.Remove(data); - } - + var datalist = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var service = new ClientBlackService(CurUser); + service.DeleteClientBlack(datalist); + return JsonSuccess(service.ProcessList().Any() ? "已经提交删除审批!" : "删除成功"); } - clientDB.SaveChanges(); - return JsonSuccess("删除成功"); + catch (Exception ex) + { + return JsonError(ex.GetBaseException().Message); + } + } + + [MyAuthorize("客户管理-黑名单审批")] + public ActionResult clientblackApproval() + { + return View(); + } + + [HttpPost, MyAuthorize("客户管理-黑名单审批")] + public JsonResult clientblackApprovalQuery(ClientBlackReq req) + { + return Json(new ClientBlackService(CurUser).ClientBlackApprovalQuery(req)); + } + + [HttpPost, MyAuthorize("客户管理-黑名单客户提交审批")] + public JsonResult clientblackSubmit(string ids) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + new ClientBlackService(CurUser).SubmitApprovalClientBlack(idList); + return JsonSuccess("提交审批成功"); + } + + [HttpPost, MyAuthorize("客户管理-黑名单客户撤回提交审批")] + public JsonResult clientblackWithdraw(string ids) + { + var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + new ClientBlackService(CurUser).WithdrawApprovalClientBlack(idList, out var withdrawCount, out var msg); + if (withdrawCount == 0) + { + return JsonError(string.IsNullOrWhiteSpace(msg) + ? "所选记录当前状态无法撤回审批" + : $"以下记录已进入后续节点无法撤回:{msg}"); + } + return JsonSuccess("撤回审批成功" + (string.IsNullOrWhiteSpace(msg) ? "" : $",以下记录已进入后续节点无法撤回:{msg}")); + } + + [HttpPost, MyAuthorize("客户管理-黑名单审批")] + public JsonResult Auditclientblack(ClientBlackAuditReq req) + { + new ClientBlackService(CurUser).AuditClientBlack(req); + return JsonSuccess("审批成功"); + } + + [MyAuthorize("客户管理-黑名单审批")] + public ActionResult clientblackView(string enid) + { + var id = DataProtectHelper.DecryptInt(enid); + var item = clientDB.client_black.FirstOrDefault(x => x.id == id); + return View(item); + } + + [MyAuthorize("客户管理-黑名单客户")] + public ActionResult clientblackLogList(int id) + { + var logs = clientDB.client_blacklog.Where(x => x.ClientBlackId == id) + .OrderByDescending(x => x.id) + .ToList(); + return View(logs); } @@ -101,4 +151,4 @@ namespace YLErp.Web.Controllers return File(bytes, xlsxMimeType, $"黑名单导出-{DateTime.Now:yyyy-MM-dd}.xlsx"); } } -} \ No newline at end of file +} diff --git a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml index 2ecdfefc..a2bbcced 100644 --- a/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml +++ b/YLErpWeb/Views/AccountOpeningProcess/Index.cshtml @@ -786,6 +786,46 @@ +
+
黑名单审批流程
+
+
+
+
申请人
+
+
+
+ +
+
+
+ +
+
+
结束流程
+
+
+
diff --git a/YLErpWeb/Views/clientblack/clientblackApproval.cshtml b/YLErpWeb/Views/clientblack/clientblackApproval.cshtml new file mode 100644 index 00000000..566df422 --- /dev/null +++ b/YLErpWeb/Views/clientblack/clientblackApproval.cshtml @@ -0,0 +1,74 @@ +@{ + ViewBag.Title = "黑名单客户审批"; + Layout = "~/Views/Shared/_MainLayout.cshtml"; + var pageObj = new + { + roles = UserBLL.GetRolesByUserId(CurUser.UserId).Select(x => x.Id) + }; +} +@section CSS{ + + +} +@section JS{ + +} +
+ + + @MyControls.SearchBtn() +
+@Html.Raw(JqGridSimple.OutTable()) diff --git a/YLErpWeb/Views/clientblack/clientblackLogList.cshtml b/YLErpWeb/Views/clientblack/clientblackLogList.cshtml new file mode 100644 index 00000000..6ccf41ad --- /dev/null +++ b/YLErpWeb/Views/clientblack/clientblackLogList.cshtml @@ -0,0 +1,14 @@ +@model IEnumerable +@{ + ViewBag.Title = "黑名单操作历史"; + Layout = "~/Views/Shared/_InfoLayout.cshtml"; +} + + + + @foreach (var item in Model ?? Enumerable.Empty()) + { + + } + +
时间操作人操作内容说明
@item.OptDate.ToString("yyyy-MM-dd HH:mm:ss")@item.OptName@item.OptType@item.Changes
diff --git a/YLErpWeb/Views/clientblack/clientblackView.cshtml b/YLErpWeb/Views/clientblack/clientblackView.cshtml new file mode 100644 index 00000000..1192edf4 --- /dev/null +++ b/YLErpWeb/Views/clientblack/clientblackView.cshtml @@ -0,0 +1,45 @@ +@using YLErp.Modules.ClientModule +@model YLErp.Model.client_black +@{ + ViewBag.Title = "黑名单客户审批"; + Layout = "~/Views/Shared/_InfoLayout.cshtml"; + var process = new ClientBlackService(CurUser).ProcessList(); + var currentNode = process.FirstOrDefault(x => x.order == Model?.ApprovalProcess); + var canAudit = currentNode != null && UserBLL.GetRolesByUserId(CurUser.UserId).Any(x => x.Id == currentNode.roleId); +} +@section JS { + +} +
+
+ @if (canAudit) + { + @MyControls.Btn("审批通过", "audit('pass');") + @MyControls.Btn("拒绝", "audit('reject');") + } +
+
+
+ + + + + + + + + + + +
客户名称@Model?.Name
黑名单备注@Model?.Remarks
审批状态@Model?.State
提交审批人@Model?.ApprovalOptName
提交审批时间@Model?.ApprovalOptDate?.ToString("yyyy-MM-dd HH:mm:ss")
审批说明
+
diff --git a/YLErpWeb/Views/clientblack/clientblacklist.cshtml b/YLErpWeb/Views/clientblack/clientblacklist.cshtml index 7ab08bb0..d8821a20 100644 --- a/YLErpWeb/Views/clientblack/clientblacklist.cshtml +++ b/YLErpWeb/Views/clientblack/clientblacklist.cshtml @@ -51,10 +51,10 @@ var colModelGrid = [{ name: 'id', label: 'id', index: 'id', width: 0, hidden: true, optionHide: true }, { - name: 'opt', label: '操作', index: 'opt', width: 150, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false, + name: 'opt', label: '操作', index: 'opt', width: 200, align: 'left', hidden: !page.canEdit, optionHide: !page.canEdit, sortable: false, formatter: function (cellValue, options, rowObject) { if (page.canEdit) { - var html = ("") + var html = ("") .template(rowObject.id); return html; } @@ -65,7 +65,9 @@ }, { name: 'Name', label: '客户名称', index: 'Name', width: 260 }, { - name: 'Remarks', label: '备注', index: 'Remarks', width: 500 + name: 'Remarks', label: '备注', index: 'Remarks', width: 500 + }, { + name: 'State', label: '状态', index: 'State', width: 120 }, { name: 'OptName', label: '操作人', index: 'OptName', width: 150 }, { @@ -146,7 +148,7 @@ function SearchClick(isSearchclick) { var listGrid = $('#listGrid'); listGrid.appendPostData({ Name: $("#Name").val() }); - listGrid.appendPostData({ OptName: $("#OptName").val() }); + listGrid.appendPostData({ ClientBlackStates: $("#ClientBlackStates").val()?.join(',') || '' }); if (typeof (isSearchclick) != "undefined" && isSearchclick) { //点击搜索时默认第一页 listGrid.jqGrid('setGridParam', {page: 1}); @@ -241,6 +243,19 @@ }); }) } + function ClientBlackSubmit() { + var ids = main.GetGridIds($('#listGrid')); + if (!ids.length) { main.message('请选择要提交的数据!'); return; } + main.post('/clientblack/clientblackSubmit', { ids: ids.toString() }).done(function () { SearchClick(); }); + } + function ClientBlackWithdraw() { + var ids = main.GetGridIds($('#listGrid')); + if (!ids.length) { main.message('请选择要撤回的数据!'); return; } + main.post('/clientblack/clientblackWithdraw', { ids: ids.toString() }).done(function () { SearchClick(); }); + } + function clientblackLogView(id) { + main.open('操作历史', '/clientblack/clientblackLogList?id=' + id, { area: ['1000px', '75%'] }); + } } @@ -267,6 +282,7 @@
+ @Html.MyAceDropdownInput("ClientBlackStates", "状态", clientblackController.GetClientBlackStates()) @if (CurUser.客户管理.黑名单客户管理) { @@ -275,6 +291,14 @@ } + @if (CurUser.客户管理.黑名单客户提交审批) + { + + } + @if (CurUser.客户管理.黑名单客户撤回提交审批) + { + + }
-@Html.Raw(JqGridSimple.OutTable()) \ No newline at end of file +@Html.Raw(JqGridSimple.OutTable()) diff --git a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js index cff6d111..68c4b3a8 100644 --- a/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js +++ b/YLErpWeb/wwwroot/Scripts/app/system/Approvalprocess.js @@ -53,7 +53,8 @@ var app = new Vue({ { text: '交易新增与修改', value: '2' }, { text: '交易了结', value: '6' }, /* { text: '资信与授信', value: '3' },*/ - { text: '出金', value: '4' } + { text: '出金', value: '4' }, + { text: '黑名单', value: '7' } ], isOpen: false, @@ -62,12 +63,14 @@ var app = new Vue({ isCredit: false, isOutCash: false, isClient: false, + isClientBlack: false, openItems: [], clientItems: [], tradeItems: [], closeItems: [], creditItems: [], outCashItems: [], + clientBlackItems: [], openCounter: 0, tradeCounter: 0, creditCounter: 0, @@ -140,6 +143,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '2') { thisObj.isOpen = false; thisObj.isTrade = true; @@ -147,6 +151,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '6') { // 需求②:交易了结流程 thisObj.isOpen = false; thisObj.isTrade = false; @@ -154,6 +159,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '3') { thisObj.isOpen = false; thisObj.isTrade = false; @@ -161,6 +167,7 @@ var app = new Vue({ thisObj.isCredit = true; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '4') { thisObj.isOpen = false; @@ -169,6 +176,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = true; thisObj.isClient = false; + thisObj.isClientBlack = false; } else if (thisObj.selected === '5') { thisObj.isOpen = false; @@ -177,6 +185,16 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = true; + thisObj.isClientBlack = false; + } + else if (thisObj.selected === '7') { + thisObj.isOpen = false; + thisObj.isTrade = false; + thisObj.isClose = false; + thisObj.isCredit = false; + thisObj.isOutCash = false; + thisObj.isClient = false; + thisObj.isClientBlack = true; } else { thisObj.isOpen = false; @@ -185,6 +203,7 @@ var app = new Vue({ thisObj.isCredit = false; thisObj.isOutCash = false; thisObj.isClient = false; + thisObj.isClientBlack = false; } thisObj.getProcess(); }, @@ -385,6 +404,18 @@ var app = new Vue({ thisObj.addCloseNode(index, child, node); return; } + else if (selectType === "7") { //黑名单 + var item = { + Type: 'ClientBlackProcess', + Index: index + 1, + SelectValue: 0 + }; + thisObj.clientBlackItems.splice(index, 0, item); + thisObj.clientBlackItems.forEach(function (x, itemIndex) { + x.Index = itemIndex + 1; + }); + return; + } }, delProcess: function (openItem) { @@ -417,6 +448,14 @@ var app = new Vue({ }); return; } + else if (selectType === "7") {//黑名单 + var index = thisObj.clientBlackItems.indexOf(openItem); + thisObj.clientBlackItems.splice(index, 1); + thisObj.clientBlackItems.forEach(function (x, itemIndex) { + x.Index = itemIndex + 1; + }); + return; + } }, addOpenProcess(index, child, node) { var thisObj = this; @@ -528,6 +567,10 @@ var app = new Vue({ thisObj.saveCloseProcess(); return; } + else if (selectType === "7") { //黑名单 + thisObj.clientBlackOk(); + return; + } }, openOk() { var thisObj = this; @@ -857,6 +900,38 @@ var app = new Vue({ }); } }, + clientBlackOk() { + var thisObj = this; + var items = thisObj.clientBlackItems; + for (var i = 0; i < items.length; i++) { + if (items[i].SelectValue === "" || items[i].SelectValue === 0) { + main.message('流程中断,请重新选择'); + return; + } + for (var j = i + 1; j < items.length; j++) { + if (parseInt(items[i].SelectValue) === parseInt(items[j].SelectValue)) { + main.message('流程包含重复项,请重新选择'); + return; + } + } + } + + if (items.length > 0) { + main.confirm("确认修改黑名单审批流程?", function () { + main.post("/AccountOpeningProcess/AddProcess", + { type: "ClientBlackProcess", data: items }, + { async: false }).done(function () { + thisObj.getProcess(); + }); + }); + } else { + main.confirm("删除审批流程后,黑名单变更会直接生效,确认删除?", function () { + main.post("/AccountOpeningProcess/AddProcess", + { type: "ClientBlackProcess" }, + { async: false }); + }); + } + }, getProcess() { var thisObj = this; thisObj.openItems = []; @@ -865,6 +940,7 @@ var app = new Vue({ thisObj.creditItems = []; thisObj.outCashItems = []; thisObj.clientItems = []; + thisObj.clientBlackItems = []; main.post("/AccountOpeningProcess/GetProcess", {}, { async: false }).done( @@ -945,6 +1021,14 @@ var app = new Vue({ triggerCondition: value.triggerCondition }); }); + (res.ClientBlackProcess || []).forEach(function (value) { + thisObj.clientBlackItems.push({ + id: value.id, + Type: value.processType, + Index: value.order, + SelectValue: value.roleId + }); + }); // 需求①:加载后把 triggerCondition(JSON)解析为结构化对象供 UI 编辑 ['openItems', 'tradeItems', 'closeItems', 'clientItems'].forEach(function (arr) { thisObj[arr].forEach(function (item) { From 692b8d753bd78662db5ff395397dde3323d873de Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 13 Aug 2026 18:31:34 +0800 Subject: [PATCH 04/43] =?UTF-8?q?docs(margin):=20=E8=A1=A5=E5=BC=BA=20Calc?= =?UTF-8?q?MarginInterest=20=E9=9A=90=E5=BC=8F=E5=81=87=E8=AE=BE/=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=B3=A8=E9=87=8A=20+=20SwapEodPositionService=20orgi?= =?UTF-8?q?nPv=20=E5=86=97=E4=BD=99=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalcMarginInterest 加 :显性化 4 点——InterestType 必须单利、SwapIntervalList 必须单段(违反静默算错、无 assert,前端保证)、方向须调用方翻转、preEod 首日就地修改;附定位指引(SwapCalcTrace 反推 accrualBasis) - SwapEodPositionService 三处 FixedAmountAndMargin 的 orginPv 赋值加注释:仅固定值腿(mode 1)生效,保证金(5/6)被 CalcMarginInterest 忽略 纯注释,零代码行为变化;全量 SwapModule 524/524 通过。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 11 +++++++++++ YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 13cf78d8..96648fc4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -953,6 +953,17 @@ namespace YLErp.Modules.SwapModule /// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 /// + /// + /// 隐式假设(违反会静默算错、无 assert 防护——由前端保证金表单保证;诊断时先核对这两条): + /// 1. InterestType 必须为单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; + /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 的复利分支不一致。 + /// 2. SwapIntervalList 必须单段。盘中用 [(PosiStartDate, rate)] 单段,不做 BuildSegmentRates 多段; + /// rate 入参须已是生效固定利率(GetInterests:746 GetFixedRate)。若保证金利率表被改成多段会漏分段。 + /// 契约与副作用: + /// 3. position.InterestDirection 须已由调用方翻转(GetInterests:742 FlipDirection);本方法不翻转。 + /// 4. preEod 在 id==0 时被就地修改(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。 + /// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。 + /// /// true=收盘归档(EOD),false=盘中平仓/互换。 /// 互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。 public swap_flow_event CalcMarginInterest( diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 9bf583aa..be097caf 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1138,6 +1138,8 @@ namespace YLErp.Modules.SwapModule positions.Add(position); List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { @@ -1276,6 +1278,8 @@ namespace YLErp.Modules.SwapModule newEodPayPosition = eodPayPosition.Clone(); newEodPayPosition.id = 0; } + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (interestModes.Contains(position.InterestMode)) { @@ -1489,6 +1493,8 @@ namespace YLErp.Modules.SwapModule Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); List intervals = position.SwapIntervalList; var tradeExtend = td.trade_extend.ExtendObj; + // orginPv 在此仅对固定值腿(mode 1)生效;保证金腿(5/6)的 orginPv 虽在此赋值, + // 但 GetInterests 保证金分支已走 CalcMarginInterest(内部自算 orginPv=PreviousBalance),忽略此处传入值。 var interestModes = MarginModes.FixedAmountAndMargin; if (eodPayPosition == null) { From 67d7733ed6f7e148ff520bc1d73868fb1f8eed59 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 08:14:32 +0800 Subject: [PATCH 05/43] =?UTF-8?q?docs(margin):=20=E4=BF=AE=E6=AD=A3=20Calc?= =?UTF-8?q?MarginInterest=20remarks=E2=80=94=E2=80=94SwapIntervalList=20?= =?UTF-8?q?=E5=8D=95=E6=AE=B5=E6=98=AF=E4=BF=9D=E8=AF=81=E9=87=91=E6=9C=AC?= =?UTF-8?q?=E6=80=A7=E8=80=8C=E9=9D=9E=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第2点措辞修正:原写"SwapIntervalList 必须单段(多段会漏分段)"误导,把业务本性误述成代码限制。 实际:保证金=固定利率(业务定义),SwapIntervalList 多段是融资腿 FR007 概念与保证金无关; CalcMarginInterest 只消费 rate(GetFixedRate 已处理分段),不直接读 SwapIntervalList, 单段 segmentRates 是对固定利率的正确建模。纯注释,零行为变化。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 96648fc4..cd3b8d70 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -954,11 +954,13 @@ namespace YLErp.Modules.SwapModule /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 /// /// - /// 隐式假设(违反会静默算错、无 assert 防护——由前端保证金表单保证;诊断时先核对这两条): - /// 1. InterestType 必须为单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; - /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 的复利分支不一致。 - /// 2. SwapIntervalList 必须单段。盘中用 [(PosiStartDate, rate)] 单段,不做 BuildSegmentRates 多段; - /// rate 入参须已是生效固定利率(GetInterests:746 GetFixedRate)。若保证金利率表被改成多段会漏分段。 + /// 前提(由前端保证金表单 + SwapTradeService 构造保证): + /// 1. InterestType=单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; + /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。 + /// 2. 保证金=固定利率——这是业务定义,不是本方法的限制。前端无分段利率入口、SwapTradeService + /// 构造单段 SwapIntervalList,故 rate 入参即全程固定利率(GetFixedRate 对单段表返回 InterestRateDefault)。 + /// 盘中 segmentRates=[(PosiStartDate, rate)] 单段是对固定利率的正确建模。SwapIntervalList 多段是融资腿 + /// FR007 的概念,与保证金无关;本方法只消费 rate(GetFixedRate 已处理分段取值),不直接读 SwapIntervalList。 /// 契约与副作用: /// 3. position.InterestDirection 须已由调用方翻转(GetInterests:742 FlipDirection);本方法不翻转。 /// 4. preEod 在 id==0 时被就地修改(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。 From 909111ad2ce7a389f93265a3537fa4266a633e24 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 08:22:49 +0800 Subject: [PATCH 06/43] =?UTF-8?q?docs(margin):=20=E4=BF=AE=E6=AD=A3=20Calc?= =?UTF-8?q?MarginInterest=20remarks=E2=80=94=E2=80=94SwapIntervalList=20?= =?UTF-8?q?=E6=98=AF=E4=BA=92=E6=8D=A2=E8=A7=82=E5=AF=9F=E6=97=A5=E6=8E=92?= =?UTF-8?q?=E6=9C=9F=E8=80=8C=E9=9D=9EFR007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三次修正(前两次基于错误理解)。查证代码后准确描述: - SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历),非 FR007 浮动(浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放分段录入 - rate 来自 GetFixedRate(SwapIntervalList 取 Date≤unwindDate 最近段) - 盘中用该 rate 全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 也是 GetFixedRate 单一值全程,不按 SwapIntervalList 切段)——阶梯利率盘中半路变更的精细处理是既有未覆盖口径,非本次引入;EOD 因每日重取 GetFixedRate 能正确反映阶梯 纯注释,零行为变化。 --- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index cd3b8d70..20af8f9d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -957,10 +957,12 @@ namespace YLErp.Modules.SwapModule /// 前提(由前端保证金表单 + SwapTradeService 构造保证): /// 1. InterestType=单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。 - /// 2. 保证金=固定利率——这是业务定义,不是本方法的限制。前端无分段利率入口、SwapTradeService - /// 构造单段 SwapIntervalList,故 rate 入参即全程固定利率(GetFixedRate 对单段表返回 InterestRateDefault)。 - /// 盘中 segmentRates=[(PosiStartDate, rate)] 单段是对固定利率的正确建模。SwapIntervalList 多段是融资腿 - /// FR007 的概念,与保证金无关;本方法只消费 rate(GetFixedRate 已处理分段取值),不直接读 SwapIntervalList。 + /// 2. rate 由 GetFixedRate 提供(SwapDealService.cs:866)——从 SwapIntervalList 取 Date ≤ unwindDate 最近段的 Rate, + /// 空表/单段时返回 InterestRateDefault。SwapIntervalList 是"互换观察日排期"(阶梯利率表 + 结息日历,非 FR007 浮动—— + /// 浮动由 FloatRateUnderlyingCode + interest_rest_days 独立驱动);保证金前端亦开放"设置观察日"分段录入。 + /// 盘中用该 rate 覆盖全程,与旧 CalcDailySimpleInterest 完全一致(BuildSegmentRates 的 spread 同样是 GetFixedRate 单一值全程, + /// 不按 SwapIntervalList 切段)——SwapIntervalList 阶梯利率在盘中半路变更的精细处理是既有未覆盖口径,非本次引入; + /// EOD 路径因每日重取 GetFixedRate(valueDate) 故能正确反映阶梯。 /// 契约与副作用: /// 3. position.InterestDirection 须已由调用方翻转(GetInterests:742 FlipDirection);本方法不翻转。 /// 4. preEod 在 id==0 时被就地修改(设 TdInterestPrincipal/PosiNotionalValue/FloatRate),与旧 CalcEodInterest 一致。 From b2cbbff9e7087e5ca52f41789a1f98f720fe9dd0 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 08:55:37 +0800 Subject: [PATCH 07/43] =?UTF-8?q?refactor(margin):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E6=8E=A5=E7=BA=BF=E7=9A=84=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E9=A2=84=E7=95=99=E6=8A=BD=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 MarginAccount 整类、IMarginResolver 子树(MarginForm/CashMargin/CreditMargin/GuaranteeMargin)、MarginBalance 只读 struct:全仓零生产实例化,结构上无法适配 CalcMarginInterest 的盘中差分模型。 - 删除仅覆盖上述死代码的 MarginLegTest(不损失活代码覆盖率)。 - 保留被 CalcMarginInterest 实际使用的 MarginCalc/MarginModes 活代码。 - 同步修剪 ARCHITECTURE.md 树图与对账表,清理 AccrualContext.cs 过期注释。 --- .../YLErp.Core/Interest/AccrualContext.cs | 2 +- .../SwapModule/Margin/MarginLegTest.cs | 121 ------------------ YLErpDAL/Modules/SwapModule/ARCHITECTURE.md | 8 +- .../Modules/SwapModule/Margin/CashMargin.cs | 10 -- .../Modules/SwapModule/Margin/CreditMargin.cs | 10 -- .../SwapModule/Margin/GuaranteeMargin.cs | 10 -- .../SwapModule/Margin/IMarginResolver.cs | 24 ---- .../SwapModule/Margin/MarginAccount.cs | 42 ------ .../SwapModule/Margin/MarginBalance.cs | 16 --- 9 files changed, 3 insertions(+), 240 deletions(-) delete mode 100644 UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs diff --git a/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs index 8e7dd77f..2a1f6273 100644 --- a/Framework/YLErp.Core/Interest/AccrualContext.cs +++ b/Framework/YLErp.Core/Interest/AccrualContext.cs @@ -18,7 +18,7 @@ public readonly struct AccrualContext /// 年化天数(365 / 360)。 public int AnnualDays { get; } - /// 舍入精度位数。默认 11(仅未接线的 MarginAccount.AccrueInterest 走此默认;生产融资腿/保证金腿均显式用 FundingLegPrecision=12)。 + /// 舍入精度位数。默认 11(生产融资腿/保证金腿均显式传入 FundingLegPrecision=12)。 public int Precision { get; } /// 可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。 diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs deleted file mode 100644 index 156eb932..00000000 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginLegTest.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using YLErp.Derivatives.Interest; -using YLErp.Modules.SwapModule.Margin; - -namespace UnitTestProject.Modules.SwapModule.Margin -{ - /// - /// 保证金账户(MarginAccount)单测。验证余额变动(追加/释放/返还)。 - /// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 - /// - [TestClass] - public class MarginLegTest - { - private const decimal Opening = 2_000_000m; - - #region MarginAccount 余额变动 - - [TestMethod] - public void 账户_初始余额等于期初保证金() - { - var account = new MarginAccount(new MarginBalance(Opening)); - Assert.AreEqual(Opening, account.Balance.Balance); - } - - [TestMethod] - public void 账户_追加保证金_余额增加() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Deposit(500_000m); - Assert.AreEqual(2_500_000m, account.Balance.Balance); - } - - [TestMethod] - public void 账户_释放保证金_余额减少() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Withdraw(800_000m); - Assert.AreEqual(1_200_000m, account.Balance.Balance); - } - - [TestMethod] - public void 账户_释放超过余额_不低于零() - { - var account = new MarginAccount(new MarginBalance(Opening)); - account.Withdraw(3_000_000m); - Assert.AreEqual(0m, account.Balance.Balance, "保证金余额不低于零"); - } - - #endregion - - #region 三种保证金形态解析器 - - [TestMethod] - public void 三种形态解析器_各自返回正确Form和余额() - { - IMarginResolver cash = new CashMargin(); - IMarginResolver credit = new CreditMargin(); - IMarginResolver guarantee = new GuaranteeMargin(); - - Assert.AreEqual(MarginForm.Cash, cash.Form); - Assert.AreEqual(MarginForm.Credit, credit.Form); - Assert.AreEqual(MarginForm.Guarantee, guarantee.Form); - - Assert.AreEqual(Opening, cash.Resolve(Opening).Balance); - Assert.AreEqual(Opening, credit.Resolve(Opening).Balance); - Assert.AreEqual(Opening, guarantee.Resolve(Opening).Balance); - } - - #endregion - - #region MarginAccount 计息 - - [TestMethod] - public void 计息_单利7天_余额200万年化3pct() - { - var account = new MarginAccount(new MarginBalance(2_000_000m)); - // 200万 × 3% / 365 × 7天 = 1150.68... - var r = account.AccrueInterest( - rate: 0.03m, - startDate: new System.DateTime(2026, 5, 4), - endDate: new System.DateTime(2026, 5, 11), - boundary: AccrualBoundary.StartOnly, - annualDays: 365); - - Assert.IsTrue(r.Accrued > 0, "7天利息应大于0"); - System.Console.WriteLine($"保证金7天利息={r.Accrued}"); - } - - [TestMethod] - public void 计息_零余额_利息为零() - { - var account = new MarginAccount(new MarginBalance(0m)); - var r = account.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - - Assert.AreEqual(0m, r.Accrued); - } - - [TestMethod] - public void 计息_释放后余额减少_利息相应减少() - { - var full = new MarginAccount(new MarginBalance(2_000_000m)); - var half = new MarginAccount(new MarginBalance(2_000_000m)); - half.Withdraw(1_000_000m); - - var rFull = full.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - var rHalf = half.AccrueInterest(0.03m, - new System.DateTime(2026, 5, 4), new System.DateTime(2026, 5, 11), - AccrualBoundary.StartOnly, 365); - - Assert.IsTrue(rHalf.Accrued < rFull.Accrued, "释放后利息应更少"); - Assert.IsTrue(System.Math.Abs(rFull.Accrued - rHalf.Accrued * 2m) < 0.01m, - "余额减半, 利息也应减半"); - } - - #endregion - } -} diff --git a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md index 43f2b474..1ddf3913 100644 --- a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md +++ b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md @@ -52,11 +52,7 @@ SwapModule/ │ ├── Margin/ 保证金(mode 5/6) │ ├── MarginModes mode 判断(含 ForLinq for EF Core) -│ ├── MarginBalance 保证金余额(值对象) -│ ├── MarginAccount 余额管理 + AccrueInterest 计息入口 -│ ├── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement) -│ ├── IMarginResolver 保证金形态接口 -│ └── Cash/Credit/Guarantee 三种形态实现 +│ └── MarginCalc 纯函数(PreviousBalance/FlipDirection/AccumulateSettlement) │ ├── ReturnLegs/ 标的端 │ ├── ReturnLegSummary 标的端汇总值 @@ -132,7 +128,7 @@ Unknown = 0 |---|---|---| | 公司行为(送股/拆股) | QtyRollforward.corpActionDeltaQty | ✅ | | 公司行为(登记日快照) | DividendCalc + BondPayment | 见 corp-action-refactor-proposal.md | -| 保证金配置/规则/占用 | MarginAccount + MarginCalc | ✅ | +| 保证金配置/规则/占用 | MarginCalc | ✅ | | RecordMarginCashFlow 迁入 Margin | AddClientCash 加 virtual | 待做 | | EOD 编排拆分 | SwapPositionCompose | 待业务需求驱动 | ``` diff --git a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs deleted file mode 100644 index 128f34ba..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/CashMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 现金保证金:余额 = 现金余额。 -public sealed class CashMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Cash; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs deleted file mode 100644 index dd3c0032..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/CreditMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 授信保证:余额 = 已用授信额度。 -public sealed class CreditMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Credit; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs b/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs deleted file mode 100644 index 8506a687..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/GuaranteeMargin.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 担保品:余额 = 担保品市值。 -public sealed class GuaranteeMargin : IMarginResolver -{ - public MarginForm Form => MarginForm.Guarantee; - - public MarginBalance Resolve(decimal postedAmount) - => new(postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs b/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs deleted file mode 100644 index 1e24f876..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/IMarginResolver.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// 保证金形态:现金 / 授信 / 担保。预留扩展。 -public enum MarginForm -{ - /// 现金保证金:余额 = 现金余额。 - Cash, - /// 授信保证:余额 = 已用授信额度。 - Credit, - /// 担保品:余额 = 担保品市值。 - Guarantee, -} - -/// -/// 按保证金形态解析余额。三种形态可互换地产出一个 MarginBalance(满足 LSP), -/// 这是保证金领域唯一合理的多态点(差异仅在"余额如何取得")。 -/// 具体余额来源(资金流水 / 授信占用 / 担保估值)后续按形态填充。 -/// -public interface IMarginResolver -{ - MarginForm Form { get; } - - MarginBalance Resolve(decimal postedAmount); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs deleted file mode 100644 index 33a5474f..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginAccount.cs +++ /dev/null @@ -1,42 +0,0 @@ -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - -namespace YLErp.Modules.SwapModule.Margin; - -/// -/// 保证金账户。管理保证金余额的变动(追加/释放/返还),并提供计息入口(预留抽象,尚未接线)。 -/// -/// 保证金是独立的资金管理概念(初始保证金/维持保证金/保证金余额/追保),与融资腿(funding leg)无关。 -/// 生产保证金计息入口为 SwapDealService.CalcMarginInterest(仍以 InterestMode 5/6 标识): -/// EOD 用昨日终本金 preEod.TdInterestPrincipal(无差分);盘中用 accrualBasis 差分(orginPv 经 PreviousBalance)。 -/// 本类尚未被生产代码实例化——其扁平"余额×利率×天数"模型无法表达盘中差分与多行分段,留作未来简化抽象。 -/// -public sealed class MarginAccount -{ - /// 当前保证金余额。 - public MarginBalance Balance { get; private set; } - - public MarginAccount(MarginBalance openingBalance) - => Balance = openingBalance; - - /// 追加保证金(余额增加)。 - public void Deposit(decimal amount) - => Balance = new MarginBalance(Balance.Balance + amount); - - /// 释放/返还保证金(余额减少,不低于 0)。 - public void Withdraw(decimal amount) - => Balance = new MarginBalance(Math.Max(0m, Balance.Balance - amount)); - - /// - /// 按当前余额计算保证金利息。委托 SwapInterest.AccrueSimple。 - /// 注意:当前未被生产代码调用——生产保证金计息入口为 SwapDealService.CalcMarginInterest - /// (处理 EOD 昨日终本金与盘中差分;本方法的扁平余额模型不覆盖盘中差分口径)。 - /// - /// 保证金利率(年化,如 0.03 = 3%)。 - /// 计息开始日。 - /// 计息结束日。 - /// 算头算尾规则。 - /// 年化天数(365 或 360)。 - public InterestResult AccrueInterest(decimal rate, System.DateTime startDate, System.DateTime endDate, AccrualBoundary boundary, int annualDays) - => SwapInterest.AccrueSimple(new AccrualContext(annualDays), Balance.Balance, rate, startDate, endDate, boundary); -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs deleted file mode 100644 index abb50ece..00000000 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginBalance.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace YLErp.Modules.SwapModule.Margin; - -/// -/// 保证金余额。现金、授信、担保等多种保证金形态的统一表达。 -/// -/// 保证金就是保证金——有余额、有利率、有利息,不存在"计息基数/Notional"概念。 -/// 余额随追加/释放/盈亏变动,利息由计息层(SwapDealService.CalcMarginInterest)按 EOD 昨日终本金 / 盘中差分口径计算。 -/// -public readonly struct MarginBalance -{ - /// 保证金余额:现金余额 / 授信占用 / 担保品市值。 - public decimal Balance { get; } - - public MarginBalance(decimal balance) - => Balance = balance; -} From bdba5fbecdf44e3275706d77dd6cd2bb35bb0061 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 10:05:01 +0800 Subject: [PATCH 08/43] =?UTF-8?q?fix(EQD-7004):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=88=86=E7=BA=A2=E7=99=BB=E8=AE=B0=E6=97=A5=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9A=84=E5=A4=9A=E6=AC=A1=E4=BB=98=E6=81=AF?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E6=B5=8B=E8=AF=95=E4=B8=8ETrace=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原修复 9df39491 仅覆盖单次登记日(4/3)的两个单点修复,本次补全 EQD-7004 的完整验证与排查手段: - 测试 GLMS20260105_0006 新增 4 个场景:5 期 reg_date 过滤口径各自命中正确子集;五期票息累计=5x36160=180800;auto 实现归0后下次登记日重新累加;4 期挂账累计读 144640 - 分红读取关键链路加 Debug 级日志(生产可关):BondPaymentService.GetBondPayments(reg_date 过滤区间+命中条数)、SwapDealService.GetPreEodPositionByDate(EOD 定位+回退)、GetPreEodDividendSum(取EOD日期+PosiDividendSum)、方案C 平仓预览/收益结算两处 DividendIn 赋值 实跑 dotnet test 全 GREEN(6/6)。任务编号 EQD-7004。 --- ...S20260105_0006_RegisterDateDividendTest.cs | 123 ++++++++++++++++++ .../Modules/EodModule/BondPaymentService.cs | 2 + .../Modules/SwapModule/SwapDealService.cs | 10 +- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs index 4ad902b2..cafe7e59 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs @@ -26,6 +26,16 @@ namespace YLErp.Modules.SwapModule private static readonly DateTime PayDate = new(2026, 4, 6); private static readonly DateTime PreRegDate = new(2026, 4, 2); + // 多次付息日历(截图:债券 230004.IB,每期票息 0.1808,共 5 次登记日) + private static readonly DateTime[] RegDates = { + new(2026, 2, 28), new(2026, 4, 3), new(2026, 4, 29), + new(2026, 5, 29), new(2026, 6, 29) + }; + private static readonly DateTime[] PayDates = { + new(2026, 3, 2), new(2026, 4, 6), new(2026, 4, 30), + new(2026, 6, 1), new(2026, 6, 30) + }; + #region 成因 A:日期口径 seam private sealed class TestableBondPaymentService : BondPaymentService @@ -60,6 +70,60 @@ namespace YLErp.Modules.SwapModule "当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。"); } + [TestMethod] + public void CauseA_MultiRegDate_跨登记日区间命中正确子集() + { + var records = Enumerable.Range(0, 5).Select(i => new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDates[i], + payment_date_pl = PayDates[i], + payment_date = PayDates[i], + payment_interest = PaymentPer100 + }).ToList(); + var svc = new TestableBondPaymentService(records); + + // 单次窗口:每个登记日各自命中 1 条(验证按 reg_date 过滤,非支付日) + for (int i = 0; i < 5; i++) + { + var prev = i == 0 ? RegDates[i].AddDays(-1) : RegDates[i - 1]; + var hit = svc.GetBondPayments(BondCode, prev, RegDates[i]); + Assert.AreEqual(1, hit.Count, $"窗口({prev:yyyy-MM-dd},{RegDates[i]:yyyy-MM-dd}] 应仅命中登记日 {RegDates[i]:yyyy-MM-dd} 那条"); + Assert.AreEqual(RegDates[i], hit[0].reg_date, "命中的应是该登记日记录"); + } + + // 长区间应命中全部 5 条,不漏不混 + var all = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]); + Assert.AreEqual(5, all.Count, "长区间(登记日1前,登记日5] 应命中全部 5 次付息"); + + // 跨登记日中间区间:(4/2, 4/29] 应命中 4/3 与 4/29 两条(不含 2/28、5/29、6/29) + var mid = svc.GetBondPayments(BondCode, new DateTime(2026, 4, 2), new DateTime(2026, 4, 29)); + Assert.AreEqual(2, mid.Count, "(4/2,4/29] 应命中 4/3+4/29 两条"); + CollectionAssert.AreEquivalent( + new[] { new DateTime(2026, 4, 3), new DateTime(2026, 4, 29) }, + mid.Select(x => x.reg_date!.Value).ToArray()); + } + + [TestMethod] + public void CauseA_MultiRegDate_CalcPayment累加五期票息() + { + var records = Enumerable.Range(0, 5).Select(i => new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDates[i], + payment_date_pl = PayDates[i], + payment_date = PayDates[i], + payment_interest = PaymentPer100 + }).ToList(); + var svc = new TestableBondPaymentService(records); + + // 长区间取全部 5 期,CalcPayment 应累加 = 5 × 36160 = 180,800(原测试仅覆盖单期) + var payments = svc.GetBondPayments(BondCode, RegDates[0].AddDays(-1), RegDates[4]); + var total = svc.CalcPayment(payments, Qty, 1, 1); + Assert.AreEqual(5 * ExpectedDividend, total, 0.01m, + "5 期票息累加应为 5 × 36,160 = 180,800;单期口径会漏计其余 4 期"); + } + #endregion #region 成因 B:T-1 快照 seam @@ -107,6 +171,65 @@ namespace YLErp.Modules.SwapModule "当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。"); } + [TestMethod] + public void CauseB_MultiRegDate_Auto实现归0后下次登记日重新累加() + { + // 模拟:登记日1(2/28)计提 36160 → auto互换实现归0(3/1) → 登记日2(4/3)再计提 36160 + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,27) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,2,28) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,3,1) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,2) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) }, + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,27), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,2,28), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,3,1), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,2), PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = ExpectedDividend, PosiQuantity = Qty }, + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 登记日2(4/3)当天手动互换:应读 4/3 EOD = 36160(第二次,非第一次已实现的、非 0) + var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)); + Assert.AreEqual(ExpectedDividend, dividend, 0.01m, + "登记日2(4/3)手动互换应读当日EOD=第二次分红36160;" + + "若读T-1(4/2=0)则漏当日,若读2/28则错取第一次已实现的。"); + } + + [TestMethod] + public void CauseB_MultiRegDate_手动互换期间分红挂账累计四期() + { + // 模拟:多次登记日之间未 auto 实现,分红挂账累加 + // 4/3=36160, 4/29=72320, 5/29=108480, 6/29=144640(4期累计) + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,3) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,4,29) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,5,29) }, + new eod_swap { SwapTradeId = TradeId, ValueDate = new DateTime(2026,6,29) }, + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,3), PosiDividendSum = 1 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,4,29), PosiDividendSum = 2 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,5,29), PosiDividendSum = 3 * ExpectedDividend, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = new DateTime(2026,6,29), PosiDividendSum = 4 * ExpectedDividend, PosiQuantity = Qty }, + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 每次登记日当天手动互换应读到该日累计值(验证多次付息累计被正确读取) + Assert.AreEqual(1 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 3)), 0.01m, "4/3 应读 36160"); + Assert.AreEqual(2 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 29)), 0.01m, "4/29 应读 72320(2期累计)"); + Assert.AreEqual(3 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 5, 29)), 0.01m, "5/29 应读 108480(3期累计)"); + // 关键:第 4 期登记日累计 = 4 × 36160 = 144640(原 9df39491 仅覆盖单期 36160,未验证多次付息累计) + Assert.AreEqual(4 * ExpectedDividend, svc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 6, 29)), 0.01m, + "6/29 应读 144640(4期累计);原 9df39491 仅覆盖单期 36160,未验证多次付息累计。"); + } + #endregion } } diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 3e89b5b4..d601e628 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -103,6 +103,8 @@ namespace YLErp.Modules.EodModule var result = QueryBondPayments(underlyingCode) .Where(x => x.reg_date > startDate && x.reg_date <= endDate) .AsNoTracking().ToList(); + Log.Debug($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " + + string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd")))); return result; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 20af8f9d..21eca92b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -277,6 +277,7 @@ namespace YLErp.Modules.SwapModule // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + Logger.Debug($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); floatEvent.DividendIn = preEodDividendSum; floatEvent.DividendPending = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; @@ -410,7 +411,9 @@ namespace YLErp.Modules.SwapModule floatEvent.PositionId = position.PositionId; // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 - floatEvent.DividendIn = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + Logger.Debug($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); + floatEvent.DividendIn = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; floatEvent.CloseFee = 0; @@ -807,7 +810,9 @@ namespace YLErp.Modules.SwapModule protected virtual decimal GetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) { var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate); - return preEod == null ? 0m : preEod.PosiDividendSum; + var sum = preEod == null ? 0m : preEod.PosiDividendSum; + Logger.Debug($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); + return sum; } /// @@ -821,6 +826,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.ValueDate <= dealDate) .OrderByDescending(o => o.ValueDate).FirstOrDefault(); var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; + Logger.Debug($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}"); return QueryPreEodPosition(tradeId, positionId, preEodDate); } From 9bc6344f18f4a5751b88e5e77fa78200be18031f Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 10:28:10 +0800 Subject: [PATCH 09/43] =?UTF-8?q?test(dividend):=20=E8=A1=A5=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=E2=80=94=E2=80=94=E7=9B=98?= =?UTF-8?q?=E4=B8=AD=E4=BA=92=E6=8D=A2DividendIn=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E7=AE=97+=E4=BF=9D=E5=AD=98+EOD=EF=BC=8C=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E5=A4=8D=E4=B8=8D=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 填补 MU_001 缺口:MU_001 的互换 DividendIn 是测试喂的常量,本测试由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD——覆盖"预览算 DividendIn + EOD 扣减"完整链路。 验证:盘中收益互换(不扣持仓) → DividendIn=GetPreEodDividendSum(读T-1) → 保存 → EOD:TdCloseDividend 扣减 DividendIn(不重复) + 当日新计进 PosiDividendSum(不丢失) + 守恒(全程新计-全程实现=末尾PosiDividendSum)。 内存 stub(DealSvcStub+EodSvcStub),不连库,进 CI。 --- .../DividendEodNoDoubleCountTest.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs new file mode 100644 index 00000000..cb588031 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -0,0 +1,163 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD, + /// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。 + /// + /// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量; + /// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD—— + /// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。 + /// + [TestClass] + public class DividendEodNoDoubleCountTest + { + private const int SwapTradeId = 9200; + private const long PositionId = 9201; + private const decimal InitialQty = 1000m; + private const decimal DailyRatePerUnit = 0.01m; // 每单位每天 0.01,便于手算 + private static readonly DateTime StartDate = new(2026, 1, 5); + + #region Stubs + + /// SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。 + private sealed class DealSvcStub : SwapDealService + { + private readonly List _eodSwaps; + private readonly List _eodPositions; + public DealSvcStub(List eodSwaps, List eodPositions) + : base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; } + public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) + => GetPreEodDividendSum(tradeId, positionId, dealDate); + protected override IQueryable QueryPreEodSwaps(int tradeId) + => _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable(); + protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) + => _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate); + } + + /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition + 线性 CalcBondPayment。 + private sealed class EodSvcStub : TestableSwapEodPositionService + { + public EodSvcStub() : base(nameof(DividendEodNoDoubleCountTest)) { } + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + int days = Math.Max(0, (int)(toDate - fromDate).TotalDays); + return DailyRatePerUnit * days * qty * shortRatio * directionRatio; + } + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = 0m; return 1.00m; } + public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) + => UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents); + public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) + => CopyEodPosition(eod, null, td, valueDate, preSettleDate); + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() => new trade + { + id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid", + StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", + OriginalStockEqvNotional = (double)(InitialQty * 1.00m) + }; + + private static swap_position CreatePosition() => new swap_position + { + id = PositionId, SwapTradeId = SwapTradeId, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiQuantity = InitialQty, PosiNotionalValue = InitialQty, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + IsInitial = true, Invalid = false, + PosiTradingFee = 0, PosiTradingFeePending = 0 + }; + + private static eod_swap_position CreateInitialEod() => new eod_swap_position + { + id = 1, SwapTradeId = SwapTradeId, PositionId = PositionId, + ValueDate = StartDate, PosiQuantity = InitialQty, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m, + RealizedDividend = 0m, PosiFeePending = 0m, + InterestProfitSum = 0m, Invalid = false + }; + + private static swap_flow_event SwapEvent(decimal dividendIn, DateTime eventDate) => new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换, + PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg) + => Assert.IsTrue(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); + + #endregion + + /// + /// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。 + /// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。 + /// + /// 序列(StartDate=1/5,每日 0.01×1000=10): + /// D1=1/6 无事件 Copy:PosiDividendSum = 0 + 10 = 10 + /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD:新计 10 - 实现 10 → PosiDividendSum=10 + /// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10) + /// + [TestMethod] + public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失() + { + var eodSvc = new EodSvcStub(); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // D1=1/6 无事件 EOD + var d1 = new DateTime(2026, 1, 6); + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); + AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)"); + + // D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成) + var d2 = new DateTime(2026, 1, 7); + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, + new List { r1 }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); + AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10"); + Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum})"); + + // 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存) + var swapEvent = SwapEvent(dividendIn, d2); + + // D2=1/7 EOD(UpdateEodPosition,真实生产递推) + var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { swapEvent }); + + // 断言:不重复 + 不丢失 + AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)"); + AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)"); + AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)"); + + // 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum + decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend; + decimal totalRealized = r2.TdCloseDividend; + AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m, + $"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})"); + + Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}"); + Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)"); + } + } +} From 37b889c3bf088f633e79908b00dd0df7e67db6e3 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 12:49:45 +0800 Subject: [PATCH 10/43] =?UTF-8?q?test(dividend):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=B9=B3=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=99=BB=E8=AE=B0=E6=97=A5=E5=85=A8=E5=B9=B3=E6=8C=89?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=9C=BA=E6=89=80=E8=A7=84=E5=AE=9A=E4=B8=8D?= =?UTF-8?q?=E4=BA=AB=E6=9C=89=E5=BD=93=E6=97=A5=E5=88=86=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全平场景端到端测试(DividendIn 由 GetPreEodDividendSum 真实算,非喂常量)。 登记日当日全平(盘中平仓→收盘持仓0),按各交易场所规定不享有登记日当日分红(股权登记日以收盘在册为准)。 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。 断言:应得(T日前待实现)==实拿(DividendIn)(不享有当日符合规定)+ EOD 不计提当日。 合并原 d9a55fd1 + 830c72c4:前者误判'丢失缺陷',后者按业务规则修正为'不享有、系统正确'。 --- .../DividendEodNoDoubleCountTest.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs index cb588031..36e8484b 100644 --- a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -103,6 +103,16 @@ namespace YLErp.Modules.SwapModule DataState = (int)SwapFlowDateStateEnum.完成 }; + private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, + PositionId = PositionId, Quantity = qty, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + TradingAmount = qty * 1.000m, + UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg) => Assert.IsTrue(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); @@ -159,5 +169,58 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}"); Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)"); } + + /// + /// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红 + /// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。 + /// + /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日); + /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日)+ PosiDividendSum=0。 + /// 即登记日当日分红既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。 + /// 应得 = T日前待实现累计(r1.PosiDividendSum);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。 + /// + [TestMethod] + public void 登记日全平_按交易场所规定不享有当日分红() + { + var eodSvc = new EodSvcStub(); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // D1=1/6 无事件 EOD + var d1 = new DateTime(2026, 1, 6); + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); + AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum"); + + // D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成) + var d2 = new DateTime(2026, 1, 7); + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, + new List { r1 }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); + AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)"); + + // 全平事件(扣全部持仓) + var closeEvent = CloseEvent(InitialQty, dividendIn, d2); + + // D2=1/7 EOD(UpdateEodPosition,全平→PosiQuantity=0) + var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { closeEvent }); + + // 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红 + // (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。 + // 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。 + // 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。 + decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有) + decimal actualGot = dividendIn + r2.PosiDividendSum; + + Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}"); + Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}"); + + // 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定) + AssertDecimalEqual(expectedTotal, actualGot, 0.01m, + $"实拿应=应得(T日前待实现{expectedTotal}),登记日全平不享有当日分红(符合交易场所规定)"); + AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)"); + AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); + } } } From fffdea4526e0baf3e67ec5e06523db0b33a2db88 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 12:53:39 +0800 Subject: [PATCH 11/43] =?UTF-8?q?fix(EQD-7004):=20=E5=88=86=E7=BA=A2?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E9=93=BE=E8=B7=AF=E6=97=A5=E5=BF=97=20Debug?= =?UTF-8?q?=20=E6=8F=90=E5=8D=87=E4=B8=BA=20Info(=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=8F=AF=E8=A7=81,k8s=20console=20=E5=8F=AF=E8=BE=93=E5=87=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bdba5fbe 中加的分红日志原为 Debug 级;但生产 NLog minlevel=Info,k8s console 打不出来。 - 按决策改为 Info 级(5 处): BondPaymentService.GetBondPayments(reg_date 过滤区间+命中条数)、SwapDealService.GetPreEodPositionByDate(EOD 定位+回退)、GetPreEodDividendSum(取EOD日期+PosiDividendSum)、方案C 平仓预览/收益结算两处 DividendIn 赋值 此后日志一律不使用 Debug 级(生产不可见)。任务编号 EQD-7004。 --- YLErpDAL/Modules/EodModule/BondPaymentService.cs | 2 +- YLErpDAL/Modules/SwapModule/SwapDealService.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index d601e628..549af3ad 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -103,7 +103,7 @@ namespace YLErp.Modules.EodModule var result = QueryBondPayments(underlyingCode) .Where(x => x.reg_date > startDate && x.reg_date <= endDate) .AsNoTracking().ToList(); - Log.Debug($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " + + Log.Info($"[分红-登记日口径] GetBondPayments underlyingCode={underlyingCode} 区间=({startDate:yyyy-MM-dd},{endDate:yyyy-MM-dd}] 按reg_date过滤, 命中 {result.Count} 条: " + string.Join(",", result.Select(r => r.reg_date?.ToString("yyyy-MM-dd")))); return result; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 21eca92b..69b0d9e5 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -277,7 +277,7 @@ namespace YLErp.Modules.SwapModule // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); - Logger.Debug($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); + Logger.Info($"[分红-平仓预览] 方案C DividendIn=DividendPending=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); floatEvent.DividendIn = preEodDividendSum; floatEvent.DividendPending = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; @@ -412,7 +412,7 @@ namespace YLErp.Modules.SwapModule // 方案C:分红收益改由上一收盘日 EOD PosiDividendSum 提供(单一可信源), // 前端 getDivindIn 不再覆盖;消除"期初持仓×totalInterest"对已平仓部分的重复计入。 decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); - Logger.Debug($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); + Logger.Info($"[分红-收益结算] DividendIn=PosiDividendSum全量 tradeId={tradeId} positionId={position.PositionId} dealDate={dealDate:yyyy-MM-dd} 值={preEodDividendSum}"); floatEvent.DividendIn = preEodDividendSum; floatEvent.UnderlyingCode = position.UnderlyingCode; floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; @@ -811,7 +811,7 @@ namespace YLErp.Modules.SwapModule { var preEod = GetPreEodPositionByDate(tradeId, positionId, dealDate); var sum = preEod == null ? 0m : preEod.PosiDividendSum; - Logger.Debug($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); + Logger.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); return sum; } @@ -826,7 +826,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.ValueDate <= dealDate) .OrderByDescending(o => o.ValueDate).FirstOrDefault(); var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; - Logger.Debug($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}"); + Logger.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}"); return QueryPreEodPosition(tradeId, positionId, preEodDate); } From 6a25e4a2d8bd2377ee3c51020effdf9f2021c1a2 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 13:13:17 +0800 Subject: [PATCH 12/43] =?UTF-8?q?test(EQD-7004):=20=E8=A1=A5=20reg=5Fdate?= =?UTF-8?q?=20=E5=8F=A3=E5=BE=84=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E2=80=94=E2=80=94EOD=E5=BC=95=E6=93=8E=E6=8C=89?= =?UTF-8?q?=E5=80=BA=E6=9D=83=E7=99=BB=E8=AE=B0=E6=97=A5=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=EF=BC=8C=E7=99=BB=E8=AE=B0=E6=97=A5T+1=E5=85=A8=E5=B9=B3/?= =?UTF-8?q?=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93=E7=BB=8FGetPreEodDividendS?= =?UTF-8?q?um=E6=AD=A3=E7=A1=AE=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwapModule/RegDateDividendEodE2ETest.cs | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs diff --git a/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs new file mode 100644 index 00000000..38b58df9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs @@ -0,0 +1,270 @@ +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.EodModule; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Linq; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定, + /// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成 + /// 线性公式(DailyRatePerUnit*days*qty),**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。 + /// + /// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentService(reg_date 口径)】, + /// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum) + /// 真正跑生产日期逻辑: + /// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径); + /// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有); + /// ③ 部分平仓 T+1:DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。 + /// + [TestClass] + public class RegDateDividendEodE2ETest + { + private const string BondCode = "230004.IB"; + private const int TradeId = 7004; + private const long PositionId = 70041; + private const decimal Qty = 20_000_000m; + private const decimal PaymentPer100 = 0.1808m; + private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100 + + private static readonly DateTime StartDate = new(2026, 4, 1); + private static readonly DateTime RegDate = new(2026, 4, 3); // 债权登记日 + private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日(与登记日差 3 天) + + #region 内存债券付息数据(reg_date 口径) + + private static List BondPayments() + => new List + { + new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDate, // 关键:分红归属按债权登记日判定 + payment_date_pl = PayDate, // 理论付息日(非归属口径) + payment_date = PayDate, // 实际付息日(非归属口径) + payment_interest = PaymentPer100 + } + }; + + #endregion + + #region BondPaymentService seam(桥接真实 reg_date 口径,内存数据) + + private sealed class RegDateBondPaymentService : BondPaymentService + { + private readonly List _data; + public RegDateBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; } + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + #endregion + + #region EOD stub(CalcBondPayment 桥接真实 BondPaymentService) + + private sealed class RegDateEodStub : TestableSwapEodPositionService + { + private readonly List _bondPayments; + public RegDateEodStub(List bondPayments) : base(nameof(RegDateDividendEodE2ETest)) { _bondPayments = bondPayments; } + + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + // 桥接真实生产口径:BondPaymentService.GetBondPayments 按 reg_date 过滤 + CalcPayment 累加 + var svc = new RegDateBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser); + return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + } + + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m }; + + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = 0m; return 1.00m; } + + public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) + => CopyEodPosition(eod, null, td, valueDate, preSettleDate); + + public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) + => UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents); + } + + #endregion + + #region Deal stub(GetPreEodDividendSum,注入 EOD 快照) + + private sealed class DealSvcStub : SwapDealService + { + private readonly List _eodSwaps; + private readonly List _eodPositions; + public DealSvcStub(List eodSwaps, List eodPositions) + : base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; } + public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) + => GetPreEodDividendSum(tradeId, positionId, dealDate); + protected override IQueryable QueryPreEodSwaps(int tradeId) + => _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable(); + protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) + => _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate); + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() => new trade + { + id = TradeId, TradeNumber = "UT-REGDATE-E2E-001", ClientId = 999999, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 4, 1), TradeStatus = "确认成交", ValidState = "Valid", + StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", + OriginalStockEqvNotional = (double)(Qty * 1.00m) + }; + + private static swap_position CreatePosition() => new swap_position + { + id = PositionId, SwapTradeId = TradeId, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = BondCode, ContractSize = 1m, + PosiQuantity = Qty, PosiNotionalValue = Qty, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + IsInitial = true, Invalid = false, + PosiTradingFee = 0, PosiTradingFeePending = 0 + }; + + private static eod_swap_position CreateInitialEod() => new eod_swap_position + { + id = 1, SwapTradeId = TradeId, PositionId = PositionId, + ValueDate = StartDate, PosiQuantity = Qty, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = BondCode, ContractSize = 1m, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m, + RealizedDividend = 0m, PosiFeePending = 0m, + InterestProfitSum = 0m, Invalid = false + }; + + private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event + { + SwapTradeId = TradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, + PositionId = PositionId, Quantity = qty, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + TradingAmount = qty * 1.000m, + UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg) + => Assert.IsTrue(System.Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); + + #endregion + + /// + /// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时, + /// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。 + /// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。 + /// + [TestMethod] + public void 登记日口径_EOD引擎按reg_date计提_非pay_date() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var initialEod = CreateInitialEod(); + + // D1=4/2(登记日前一日):窗口 (4/1,4/2] 无登记日 → 0 + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, new DateTime(2026, 4, 2), StartDate); + AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无登记日)"); + AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无登记日)"); + + // D2=4/3(登记日):窗口 (4/2,4/3] 命中 reg_date=4/3 → 36160 + var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, r2.TdPosiDividend, 0.01m, + "4/3 登记日当日应计提 36160(按 reg_date 口径);若按支付日(pay_date=4/6)则此处为 0(漏计)。"); + AssertDecimalEqual(ExpectedDividend, r2.PosiDividendSum, 0.01m, "4/3 累计=36160"); + + // D3=4/6(支付日,非登记日):窗口 (4/3,4/6] 不含任何 reg_date(4/3 不>4/3;4/6 是支付日非登记日)→ 0 + var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate); + AssertDecimalEqual(0m, r3.TdPosiDividend, 0.01m, + "4/6 支付日不应计提(分红归属按 reg_date,不是 pay_date);线性 stub 因按天数算会在此误计。"); + AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计仍为 36160(支付日不重复计提)"); + + Console.WriteLine($"[reg_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}(登记日计提), 4/6={r3.PosiDividendSum}(支付日不计提)"); + } + + /// + /// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红; + /// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。 + /// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。 + /// + [TestMethod] + public void 登记日下一日全平_经GetPreEodDividendSum读到登记日分红() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // T日=4/3(登记日)EOD:引擎算出分红 36160(reg_date 口径) + var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计分红=36160"); + + // T+1=4/4 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160 + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } }, + new List { rReg }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4)); + AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, + "T+1(4/4) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/3)EOD 分红 36160(收盘在册→享有);" + + "若 < 严格小于 dealDate 读 T-1(4/2=0) 则漏读登记日当日。"); + Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}"); + + // T+1=4/4 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0 + var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate, + new List { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 4)) }); + + // 实拿 = DividendIn(本次落袋) + 末尾 PosiDividendSum(剩余挂账) = 应得(T日前待实现=持有至登记日) + decimal actualGot = dividendIn + rT1.PosiDividendSum; + AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得(持有至登记日享有的 36160)"); + AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非登记日,EOD 不计提当日"); + AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); + Console.WriteLine($"[T+1 全平] 应得={ExpectedDividend}, 实拿={actualGot}, 末尾PosiDividendSum={rT1.PosiDividendSum}"); + } + + /// + /// 部分平仓 T+1:当前生产行为记录(非修复目标)。 + /// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放), + /// 故 DividendIn=全量 36160;T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。 + /// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。 + /// + [TestMethod] + public void 部分平仓_T1_DividendIn为全量_剩余PosiDividendSum归0() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // T日=4/3(登记日)EOD:累计 36160 + var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计=36160"); + + // T+1=4/4 盘中部分平仓(50%):GetPreEodDividendSum 返回【全量】36160(不按比例缩放) + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } }, + new List { rReg }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4)); + AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1:DividendIn 仍为全量 36160(非按 50% 缩放)"); + + // T+1=4/4 EOD 部分平仓(Quantity=Qty/2):PosiQuantity>0;TdPosiDividend=0(非登记日), + // PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0 + var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate, + new List { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 4)) }); + + AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)"); + AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, + "部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0(当前生产行为:DividendIn 不按比例缩放)"); + Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}"); + } + } +} From 896a5c3dcc2cd21eff215b7ecf2646540434f729 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 13:37:52 +0800 Subject: [PATCH 13/43] =?UTF-8?q?test(EQD-7004):=20DividendEodNoDoubleCoun?= =?UTF-8?q?tTest=20=E7=9A=84=20EodSvcStub.CalcBondPayment=20=E7=94=B1?= =?UTF-8?q?=E7=BA=BF=E6=80=A7=E5=81=87=E5=85=AC=E5=BC=8F=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E7=9C=9F=E5=AE=9E=20BondPaymentService(reg?= =?UTF-8?q?=5Fdate=20=E5=8F=A3=E5=BE=84)=EF=BC=8C=E4=BD=BF=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E6=97=A5=E5=85=A8=E5=B9=B3/=E7=9B=98=E4=B8=AD?= =?UTF-8?q?=E4=BA=92=E6=8D=A2=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E8=B7=91=E7=94=9F=E4=BA=A7=E7=A5=A8=E6=81=AF?= =?UTF-8?q?=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DividendEodNoDoubleCountTest.cs | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs index 36e8484b..ef5a8622 100644 --- a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -1,5 +1,7 @@ +using YLErp; using YLErp.DBModels; using YLErp.DBModels.Enums; +using YLErp.Modules.EodModule; namespace YLErp.Modules.SwapModule { @@ -17,9 +19,21 @@ namespace YLErp.Modules.SwapModule private const int SwapTradeId = 9200; private const long PositionId = 9201; private const decimal InitialQty = 1000m; - private const decimal DailyRatePerUnit = 0.01m; // 每单位每天 0.01,便于手算 + private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10 private static readonly DateTime StartDate = new(2026, 1, 5); + #region 内存债券付息数据(reg_date 口径,真实生产 GetBondPayments 读取) + + private const string BondUnderlying = "210210.IB"; + private static List BondPayments() => new List + { + // 登记日 1/6、1/7 各一期;支付日滞后若干日(刻意与登记日不同,验证按 reg_date 而非 pay_date 计提) + new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 9), payment_date = new DateTime(2026, 1, 9), payment_interest = RegPer100 }, + new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 7), payment_date_pl = new DateTime(2026, 1, 10), payment_date = new DateTime(2026, 1, 10), payment_interest = RegPer100 }, + }; + + #endregion + #region Stubs /// SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。 @@ -37,14 +51,25 @@ namespace YLErp.Modules.SwapModule => _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate); } - /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition + 线性 CalcBondPayment。 + /// 真实 BondPaymentService(reg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。 + private sealed class RealBondPaymentService : BondPaymentService + { + private readonly List _data; + public RealBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; } + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition;CalcBondPayment 桥接真实 BondPaymentService(reg_date 口径,不再用线性假公式)。 private sealed class EodSvcStub : TestableSwapEodPositionService { - public EodSvcStub() : base(nameof(DividendEodNoDoubleCountTest)) { } + private readonly List _bondPayments; + public EodSvcStub(List bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; } protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { - int days = Math.Max(0, (int)(toDate - fromDate).TotalDays); - return DailyRatePerUnit * days * qty * shortRatio * directionRatio; + // 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty) + var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser); + return svc.CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); } protected override underlying_manager GetUnderlyingData(string underlyingCode) => new underlying_manager { ValueAddedTax = 0m }; @@ -122,15 +147,15 @@ namespace YLErp.Modules.SwapModule /// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。 /// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。 /// - /// 序列(StartDate=1/5,每日 0.01×1000=10): - /// D1=1/6 无事件 Copy:PosiDividendSum = 0 + 10 = 10 - /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD:新计 10 - 实现 10 → PosiDividendSum=10 + /// 序列(StartDate=1/5,reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10): + /// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10,PosiDividendSum=10 + /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10 /// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10) /// [TestMethod] public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失() { - var eodSvc = new EodSvcStub(); + var eodSvc = new EodSvcStub(BondPayments()); var td = CreateTrade(); var position = CreatePosition(); var initialEod = CreateInitialEod(); @@ -174,15 +199,15 @@ namespace YLErp.Modules.SwapModule /// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红 /// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。 /// - /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日); - /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日)+ PosiDividendSum=0。 - /// 即登记日当日分红既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。 - /// 应得 = T日前待实现累计(r1.PosiDividendSum);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。 + /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红); + /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7)+ PosiDividendSum=0。 + /// 即登记日当日分红(reg_date 1/7 的 10)既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。 + /// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。 /// [TestMethod] public void 登记日全平_按交易场所规定不享有当日分红() { - var eodSvc = new EodSvcStub(); + var eodSvc = new EodSvcStub(BondPayments()); var td = CreateTrade(); var position = CreatePosition(); var initialEod = CreateInitialEod(); From 58bd27c5968a39667a45159deccbaa374f0f74eb Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 14:24:23 +0800 Subject: [PATCH 14/43] =?UTF-8?q?test(EQD-7004):=20=E8=A1=A5=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=B9=B3=E4=BB=93=E8=B7=AF=E5=BE=84=E5=A4=9A=E6=AC=A1?= =?UTF-8?q?=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93=E4=B8=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=E7=9A=84=E5=AE=9E=E8=AF=81=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=A9=B1=E5=8A=A8=E7=9C=9F=E5=AE=9E=20BondPa?= =?UTF-8?q?ymentService.CalcPayment(reg=5Fdate=20=E5=8F=A3=E5=BE=84=C3=97?= =?UTF-8?q?=E5=BD=93=E6=AC=A1=E5=B9=B3=E4=BB=93=E9=87=8F=20unwindQty)?= =?UTF-8?q?=EF=BC=8C=E9=AA=8C=E8=AF=81=E6=80=BB=E9=A2=9D=E6=8C=89=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E6=97=A5=E6=8C=81=E4=BB=93=E5=88=86=E6=91=8A=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E8=87=AA=E6=B4=BD=E5=A4=9A=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AutoUnwindMultiPartialDividendTest.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs diff --git a/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs b/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs new file mode 100644 index 00000000..ea5035a9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/AutoUnwindMultiPartialDividendTest.cs @@ -0,0 +1,69 @@ +using YLErp.Modules.EodModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 自动平仓路径(AuotoSwapUnwind → EnrichDividendIn, SwapDealService.cs:1668-1687)多次部分平仓是否多算的实证。 + /// EnrichDividendIn 核心:GetBondPayments(td.StartDate, closeDate) × unwindQty(当次平仓量,非剩余持仓)。 + /// 本测试直接驱动真实 BondPaymentService.CalcPayment(与 EnrichDividendIn 等价:GetBondPayments 按 reg_date 过滤 + CalcPayment × unwindQty), + /// 内存注入 reg_date 数据,不连库。完整 AuotoSwapUnwind 链路因 EnrichDividendIn 直接 new BondPaymentService 查库、无内存 seam 注入点,故用计算核心等价验证。 + /// + /// 结论验证:多次跨越登记日的部分平仓,每次 × 当次平仓量 → 总额 = 各批按登记日持有 × 平仓量分摊, + /// 不自洽多算、不重复计入重叠窗口。 + /// (纠正此前"从建仓日重算导致重复计入"的推断:该推断误以为 CalcPayment 乘剩余持仓,实际乘当次 unwindQty。) + /// + [TestClass] + public class AutoUnwindMultiPartialDividendTest + { + private const string BondCode = "230004.IB"; + private static readonly DateTime StartDate = new(2026, 1, 5); + private static readonly DateTime Reg1 = new(2026, 5, 15); // 每百元付息 10 + private static readonly DateTime Reg2 = new(2026, 6, 15); // 每百元付息 12 + + private sealed class BridgeBps : BondPaymentService + { + public BridgeBps(OptUserInfo u) : base(u) { } + protected override IQueryable QueryBondPayments(string underlyingCode) + => new List + { + new BondPayment { underlyingCode = BondCode, reg_date = Reg1, payment_date_pl = Reg1, payment_date = Reg1, payment_interest = 10m }, + new BondPayment { underlyingCode = BondCode, reg_date = Reg2, payment_date_pl = Reg2, payment_date = Reg2, payment_interest = 12m }, + }.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + // 等价于 EnrichDividendIn 的数值核心:GetBondPayments(StartDate, closeDate) × unwindQty + private static decimal EnrichOnce(DateTime closeDate, decimal unwindQty) + { + var svc = new BridgeBps(OptUserInfo.UnitTestUser); + return svc.CalcPayment(BondCode, StartDate, closeDate, unwindQty, 1, 1); + } + + [TestMethod] + public void 多次部分平仓_自动路径总额按登记日持仓分摊_不自洽多算() + { + decimal totalFace = 10_000m; // 总面额 1 万元 + decimal halfFace = totalFace / 2m; // 每次平一半 + + // 第一次 5/20 平一半:窗口(Start,5/20] 仅含 reg1 → 10 × 5000/100 = 500 + var d1 = EnrichOnce(new DateTime(2026, 5, 20), halfFace); + // 第二次 6/20 平一半:窗口(Start,6/20] 含 reg1+reg2 → (10+12) × 5000/100 = 1100 + var d2 = EnrichOnce(new DateTime(2026, 6, 20), halfFace); + var total = d1 + d2; + + // 经济应得(登记日持有规则): + // 第一批5000元:5/15持有✓(10)、6/15未持有✗ → 10×5000/100 = 500 + // 第二批5000元:5/15持有✓(10)、6/15持有✓(12) → 22×5000/100 = 1100 + decimal expected = 10m * halfFace / 100m + (10m + 12m) * halfFace / 100m; + + Assert.AreEqual(500m, d1, 0.001m, "第一次(5/20)只含 reg1 = 500"); + Assert.AreEqual(1100m, d2, 0.001m, "第二次(6/20)含 reg1+reg2 = 1100"); + Assert.AreEqual(expected, total, 0.001m, + "两次部分平仓总额 = 按登记日持有×平仓量分摊的应得值,重叠窗口不重复计同量(纠正:乘当次 unwindQty 而非剩余持仓)"); + + // 反证:若手动路径口径(第一次平仓即给全量待实现 = 两次分红×总面额)会多算 + decimal manualFullIfFirst = (10m + 12m) * totalFace / 100m; // 2200 + Assert.IsTrue(manualFullIfFirst > total, + "反证:手动全量落袋口径(2200) > 自动分摊口径(1600),多算方是手动路径而非自动路径"); + } + } +} From c0ac749e5b7a299ef7c93aa026757b29c44426b9 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 15:14:09 +0800 Subject: [PATCH 15/43] =?UTF-8?q?refactor(swap):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E6=8E=A5=E7=BA=BF=E7=9A=84=E8=A1=A1=E6=B3=B0=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E6=B6=88=E8=B4=B9=E6=AD=BB=E9=93=BE=20AutoSwapUnwindF?= =?UTF-8?q?romConsumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 SwapDealService.AutoSwapUnwindFromConsumer 及其私有辅助 GetUnwindInterestsByHT(全仓零调用方,随山证 v2.3.0 拷贝引入后从未接线) - git rm 删除仅被该死链引用的 HengTaiModel/SwapUnwindReq.cs(全仓零引用) - 修正 UnderlyingEntryFullPriceLeg.cs 中指向已删方法的文档注释 - 保留真实平仓路径 DealUnwind / CalcCloseAmount 等共享活代码 --- YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs | 67 -------- .../UnderlyingEntryFullPriceLeg.cs | 4 +- .../Modules/SwapModule/SwapDealService.cs | 149 +----------------- 3 files changed, 3 insertions(+), 217 deletions(-) delete mode 100644 YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs diff --git a/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs b/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs deleted file mode 100644 index 40aea13e..00000000 --- a/YLErpDAL/Model/HengTaiModel/SwapUnwindReq.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace YLErp.Model.HengTaiModel -{ - public class SwapUnwindReq - { - public SwapUnwindReq() { - ACCTSWAP_TERMINATE = new SwapUnwindData(); - } - public SwapUnwindData ACCTSWAP_TERMINATE {get;set;} - } - public class SwapUnwindData - { - /// - /// 客户交易号 - /// - public string CUSTORDID { get; set; } - /// - /// 返回的时候EXT_NO 对应推送的CUSTORDID - /// - public string EXT_NO { get; set; } - /// - /// 合约编号,推送不需要给,返回对应推送的EXT_NO - /// - public string CONTRACT_CODE { get; set; } - /// - /// 终止类型 全部终止 1 部分终止 0 - /// - public string TERMINATE_TYPE { get; set; } - /// - /// 终止数量 - /// - public string TERMINATE_COUNT { get; set; } - /// - /// 终止日期 - /// - public string TERMINATE_DAY { get; set; } - /// - /// 支付日期 - /// - public string PAY_DAY { get; set; } - /// - /// 资产端终止金额 不可为空 - /// - public string ZCD_AMOUNT { get; set; } - /// - /// 固定端终止金额 不可为空 - /// - public string GDD_AMOUNT { get; set; } - /// - /// 交易状态 不可为空 0新建,1审批中 - /// - public string ORDSTATUS { get; set; } - /// - /// 固定端费用 - /// - public string FIX_FEE { get; set; } - /// - /// 资产端费用 - /// - public string ASSET_FEE { get; set;} - } -} diff --git a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs index 6838f7e0..a1294649 100644 --- a/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs +++ b/YLErpDAL/Modules/SwapModule/FundingLegs/UnderlyingEntryFullPriceLeg.cs @@ -1,4 +1,4 @@ -using YLErp.DBModels; +using YLErp.DBModels; namespace YLErp.Modules.SwapModule.FundingLegs; @@ -7,7 +7,7 @@ namespace YLErp.Modules.SwapModule.FundingLegs; /// 计息基数 = 标的期初含费全价(PosiGrossPrice/EntryDirtyPrice) × 数量。 /// "期初(Entry)"是关键——建仓时点的全价,非当前估值全价。 /// 主路径 CalcNotionalByMode 公式与合约名义本金规模(2)相同; -/// 差异在衡泰路径会乘 grossPrice 折算(SwapDealService.GetUnwindInterestsByHT), +/// 衡泰回执折算路径(原 SwapDealService.GetUnwindInterestsByHT 乘 grossPrice 折算)已随死链清理移除; /// 以及 EOD 复利部分平仓后直接返回剩余本金(禁止反推,SwapEodPositionService:1458-1465)。 /// public sealed class UnderlyingEntryFullPriceLeg : IFundingLegStrategy diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 69b0d9e5..7990a964 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1,4 +1,4 @@ -using MoreLinq.Extensions; +using MoreLinq.Extensions; using Newtonsoft.Json; using YLErp.BLL; using YLErp.BLL.Eod; @@ -1696,153 +1696,6 @@ namespace YLErp.Modules.SwapModule return data.ValueAddedTax ?? 0; } - /// - /// 衡泰新增平仓事件 - /// - /// - /// - /// - /// - /// - public void AutoSwapUnwindFromConsumer(trade td, DateTime valueDate, DateTime payDate, decimal markClosePnl, decimal tradeinfFee, decimal interestAmount, decimal fee, decimal unwindQty, bool allClose) - { - List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; - var dealDate = valueDate; - var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); - td.trade_extend = tradeExtend; - var position = DbContext.swap_position.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial && !x.Invalid).FirstOrDefault(); - var preDealDate = GetPreDealDate(td.id, dealDate, eventTypes); - swap_flow_event floatEvent = new swap_flow_event(); - UnwindData unwindData = new UnwindData(); - unwindData.CloseType = 2; - unwindData.StartDate = td.TradeDate.Value; - if (preDealDate.HasValue) - { - unwindData.StartDate = preDealDate.Value; - } - unwindData.ValueDate = dealDate; - floatEvent.EventDate = dealDate; - unwindData.UnwindDate = QdpCalendarHelper.GetNonHoliday(dealDate.AddDays(1)); - floatEvent.UnwindDate = unwindData.UnwindDate; - floatEvent.PayDate = payDate; - unwindData.PayDate = floatEvent.PayDate; - floatEvent.SwapTradeId = td.id; - floatEvent.SwapTradeNo = td.TradeNumber; - unwindData.SwapTradeId = td.id; - unwindData.StructureType = td.StructureType; - unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); - unwindData.NotionalQty = position.PosiQuantity; - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); - unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount); - unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; - unwindData.CloseMethod = allClose ? (int)CloseMethodEnum.全部平仓 : (int)CloseMethodEnum.部分平仓; - unwindData.ClosePercent = allClose ? 1 : unwindQty / unwindData.NotionalQty; - unwindData.CloseNotionalValue = allClose ? unwindData.PosiNotionalValue : unwindQty; - unwindData.CloseQty = allClose ? unwindData.PositionQty : unwindQty; - if (position != null) - { - decimal floatRatio = position.PosiDirection == 1 ? 1m : -1m; - floatEvent.PositionId = position.id; - floatEvent.EventType = (int)SwapEventTypeEnum.平仓; - floatEvent.EventReason = "接口合约终止交易"; - floatEvent.DividendIn = 0; - floatEvent.UnderlyingCode = position.UnderlyingCode; - floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType; - floatEvent.CloseFee = 0; - floatEvent.BeforeCloseFee = position.PosiTradingFee + position.PosiTradingFeePending; - floatEvent.PayDirection = position.PosiDirection; - floatEvent.PosiGrossPrice = position.PosiGrossPrice; - floatEvent.PosiNetPrice = position.PosiNetPrice; - floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; - floatEvent.TradingFeePending = position.PosiTradingFeePending * unwindData.ClosePercent; - floatEvent.TradingFee = tradeinfFee - floatEvent.TradingFeePending; - floatEvent.MarkClosePnl = markClosePnl; - floatEvent.TradingAmount = floatEvent.Quantity * floatEvent.ContractSize; - floatEvent.PositionType = position.PositionType; - floatEvent.Quantity = position.PosiQuantity; - floatEvent.PositionQty = 0; - floatEvent.ContractSize = position.ContractSize; - floatEvent.DataState = (int)SwapFlowDateStateEnum.完成; - floatEvent.InterestMode = position.InterestMode; - floatEvent.TradingAmount = unwindData.CloseQty; - floatEvent.ClientId = td.ClientId; - floatEvent.OptLog = "衡泰同步"; - floatEvent.SetOpt(UserInfo); - } - unwindData.FlowEvents.Add(floatEvent); - var interestPositions = GetUnwindInterestsByHT(unwindData, td, interestAmount, fee); - unwindData.FlowEvents.AddRange(interestPositions); - CalcCloseAmount(unwindData); - DealUnwind(unwindData, td, "合约终止接口回执"); - } - private List GetUnwindInterestsByHT(UnwindData unwindData, trade td, decimal interestAmount, decimal fee) - { - List interests = new List(); - var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.Invalid && x.IsInitial && x.PosiDirection > 0).ToList(); - var position = allpositions.Where(x => ConsTrade.InterestModels.Contains(x.InterestMode)).FirstOrDefault(); - if (position == null) - { - return interests; - } - var grossPrice = allpositions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0; - var _closePosiNotionalValue = unwindData.CloseNotionalValue; - var _posiNotionalValue = unwindData.PosiNotionalValue; - var newClosePercent = unwindData.ClosePercent; - foreach (var item in allpositions) - { - var positionClone = item.Clone(); - var swapIntervalToday = position.SwapIntervalList.OrderByDescending(o => o.Date).FirstOrDefault(); - if (item.InterestMode == (int)InterestModeEnum.固定值) - { - _closePosiNotionalValue = item.InterestPrincipalFix; - _posiNotionalValue = item.InterestPrincipalFix; - newClosePercent = 1m; - } - else if (item.InterestMode == (int)InterestModeEnum.标的期初全价) - { - _closePosiNotionalValue = _posiNotionalValue * grossPrice * newClosePercent; - _posiNotionalValue = _posiNotionalValue * grossPrice; - } - else if (MarginModes.Contains(item.InterestMode)) - { - _closePosiNotionalValue = 0; - positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); - } - decimal rate = item.InterestRateDefault; - if (swapIntervalToday != null)//当日无适用观察日 - { - rate = swapIntervalToday.Rate; - } - swap_flow_event interest = new swap_flow_event(); - interest.SwapTradeId = td.id; - interest.SwapTradeNo = td.TradeNumber; - interest.EventType = (int)SwapEventTypeEnum.平仓; - interest.EventReason = "衡泰同步平仓"; - interest.EventDate = unwindData.ValueDate; - interest.PositionId = item.id; - interest.InterestDirection = positionClone.InterestDirection; - interest.InterestRate = rate; - interest.InterestPrincipal = _closePosiNotionalValue; - interest.InterestSwapInterval = item.InterestSwapInterval; - interest.InterestMode = item.InterestMode; - interest.FloatRate = item.FloatRate; - interest.DataState = (int)SwapFlowDateStateEnum.完成; - interest.ClientId = td.ClientId; - interest.UnwindDate = unwindData.ValueDate; - interest.PayDate = unwindData.PayDate; - if (position != null && item.id == position.id) - { - interest.InterestAmount = interestAmount; - interest.TdInterestAmount = interestAmount; - interest.InterestClosePnL = interestAmount; - interest.InterestFee = fee; - } - UpdateDbOption(interest); - interests.Add(interest); - } - - return interests; - } private void DealUnwind(UnwindData unwindData, trade td, string actionMsg = "系统操作_自动平仓") { int clientCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapRealizedPnL), ClientCashInCashOut.系统操作_平仓费, unwindData.ValueDate); From e2431e9d44c33607cf650d774b82afe67f96e8d4 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 15:31:04 +0800 Subject: [PATCH 16/43] =?UTF-8?q?refactor(accrual):=20=E8=AE=A1=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=95=B4=E4=BD=93=E8=BF=81=E5=85=A5=20DAL?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20Accrual/InterestMath?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=20Core=20=E6=9C=AA=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=E5=AD=A4=E5=84=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搬迁(算法体逐字未动,仅换命名空间与归属): - SwapInterest.Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult → YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs - AccrualTrace → Accrual/AccrualTrace.cs(被迫同迁:其 MarkStart 引用 AccrualBoundary, Core 不能反向依赖 DAL) - 引用切换:Simple/CompoundInterestAccrual、AccrualPolicy、SwapCalcTrace、SwapDealService (保留 using YLErp.Derivatives.Interest——IIndexFixer/IndexFixerBase 留 Core) 删除(零生产引用,孤儿清零): - Core:SwapInterest.cs 算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/ AccrueUnrealized/ToInterestRate,未接线且与 DAL 生产实现舍入/rollover 口径已分叉)、 AccrualContext.cs、InterestRate.cs - DAL:AccrualState.cs(零引用死类) - 测试:SwapInterest_CompoundInArrears_RolloverTimingTests.cs(仅测已删原语) 验证:两解决方案 Rebuild 0 错误;磁盘 SwapInterest. 残留 0;影子/分红/场景 86/86 通过 (含 Accrual 3 影子对账、Margin 影子、divPower 新增 AutoUnwindMultiPartial)。 注:AccrualContext 默认精度 11 与生产 12 的分叉隐患随删除一并消除; 已删原语若将来重建须先补对账测试,勿凭记忆复原(ARCHITECTURE.md 已留警告)。 --- .../YLErp.Core/Interest/AccrualContext.cs | 29 -- Framework/YLErp.Core/Interest/InterestRate.cs | 71 ----- Framework/YLErp.Core/Interest/SwapInterest.cs | 291 ------------------ .../Accrual/CompoundEodShadowTest.cs | 2 - .../Accrual/CompoundPeriodShadowTest.cs | 1 - ...t_CompoundInArrears_RolloverTimingTests.cs | 144 --------- .../Margin/MarginInterestShadowTest.cs | 1 - YLErpDAL/Modules/SwapModule/ARCHITECTURE.md | 18 +- .../SwapModule/Accrual/AccrualPolicy.cs | 4 +- .../SwapModule/Accrual/AccrualState.cs | 49 --- .../SwapModule/Accrual}/AccrualTrace.cs | 12 +- .../Accrual/CompoundInterestAccrual.cs | 15 +- .../SwapModule/Accrual/InterestMath.cs | 104 +++++++ .../Accrual/SimpleInterestAccrual.cs | 15 +- YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs | 5 +- .../Modules/SwapModule/SwapDealService.cs | 7 +- corp-action-refactor-proposal.md | 2 +- 17 files changed, 141 insertions(+), 629 deletions(-) delete mode 100644 Framework/YLErp.Core/Interest/AccrualContext.cs delete mode 100644 Framework/YLErp.Core/Interest/InterestRate.cs delete mode 100644 Framework/YLErp.Core/Interest/SwapInterest.cs delete mode 100644 UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs rename {Framework/YLErp.Core/Interest => YLErpDAL/Modules/SwapModule/Accrual}/AccrualTrace.cs (94%) create mode 100644 YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs diff --git a/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs deleted file mode 100644 index 2a1f6273..00000000 --- a/Framework/YLErp.Core/Interest/AccrualContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace YLErp.Core.Interest; - -/// -/// 计息执行上下文:把"与具体金额/利率无关"的横向参数(年化天数、精度、trace 收集器) -/// 打包成一个只读值对象,避免每个计息方法都重复携带这些参数。 -/// -/// 为何 trace 是"成员"而非散落参数:利息纯函数(AccrueSimple / AccrueCompoundInArrears) -/// 的核心职责是算账,trace 只是可观测性的旁路。把 trace 作为上下文的成员传入, -/// 调用点只需传一个 ctx,签名更干净;同时 ctx 是只读值对象,不破坏纯函数 -/// (无共享可变状态 → 线程安全、可重入、可测)。切勿把 trace 设成类的实例/静态字段, -/// 那会让并发的两笔交易共用同一 trace、并使函数带隐藏状态。 -/// -/// 与 AccrualState(跨日滚动本金状态)/ AccrualPolicy(EOD 会计政策)正交: -/// 本上下文只描述"如何算 + 往哪记",不持有任何交易进度。 -/// -public readonly struct AccrualContext -{ - /// 年化天数(365 / 360)。 - public int AnnualDays { get; } - - /// 舍入精度位数。默认 11(生产融资腿/保证金腿均显式传入 FundingLegPrecision=12)。 - public int Precision { get; } - - /// 可选 trace 收集器;为 null 时不记录(纯计算场景直接传 null,与开关无关)。 - public AccrualTrace? Trace { get; } - - public AccrualContext(int annualDays, int precision = 11, AccrualTrace? trace = null) - => (AnnualDays, Precision, Trace) = (annualDays, precision, trace); -} diff --git a/Framework/YLErp.Core/Interest/InterestRate.cs b/Framework/YLErp.Core/Interest/InterestRate.cs deleted file mode 100644 index b5a6def1..00000000 --- a/Framework/YLErp.Core/Interest/InterestRate.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; - -namespace YLErp.Core.Interest; - -/// -/// 利率 + 计息方式(单利 / 复利 / 连续复利)。 -/// -/// 通用金融原语,与互换、衍生品、任何具体业务均无耦合——谁需要算利息都能用。 -/// 利息计算不是互换特有的,所以它不住在 SwapModule,也不带任何 swap 词汇。 -/// -/// 用法(年化时间 t,如 30天/365): -/// -/// 计息因子 = ;含息额 = 本金 × 因子; -/// 利息 = 本金 × (因子 − 1) = -/// -/// -/// 与 QuantLib 模型一致:单利 / 复利 / 连续复利只是 的一个分支, -/// 不是三套独立方法。TRS 的"重置日并本金"属于离散复利,用 -/// 按段计息、段末把利息滚入本金即可(见 SwapInterest.AccrueCompoundInArrears),无需 Pow/Exp,decimal 精度无损。 -/// -/// 互换特有的会计态(每日先舍入再乘天数、平仓缩放、跨日滚动本金)不属于本原语, -/// 请在各自的 accrual 层处理。 -/// -public enum Compounding -{ - /// 单利:因子 = 1 + r·t。 - Simple, - /// 复利(理想化闭式):因子 = (1 + r/f)^(f·t),f 为年复利频次。 - Compounded, - /// 连续复利:因子 = e^(r·t)。 - Continuous -} - -/// -/// 不可变利率值对象。构造即完整,无副作用。 -/// -public readonly struct InterestRate -{ - /// 年化利率 r。 - public decimal Rate { get; } - - /// 计息方式。 - public Compounding Compounding { get; } - - /// 年复利频次(仅 使用,其余忽略,默认 1)。 - public int Frequency { get; } - - public InterestRate(decimal rate, Compounding compounding, int frequency = 1) - => (Rate, Compounding, Frequency) = (rate, compounding, frequency); - - /// - /// 计息因子(输入年化时间 t)。 - /// - /// :decimal 精确运算。 - /// / :闭式(double 计算后回 decimal), - /// 满足通用定价;若要 decimal 精度的离散重置日复利,请用 Simple 按段计息并滚动本金。 - /// - /// - public decimal CompoundFactor(decimal t) - => Compounding switch - { - Compounding.Simple => 1m + Rate * t, - Compounding.Compounded => (decimal)Math.Pow((double)(1m + Rate / Frequency), (double)(Frequency * t)), - Compounding.Continuous => (decimal)Math.Exp((double)(Rate * t)), - _ => throw new ArgumentOutOfRangeException(nameof(Compounding)) - }; - - /// 利息 = 本金 × (因子 − 1)。 - public decimal Interest(decimal principal, decimal t) - => principal * (CompoundFactor(t) - 1m); -} diff --git a/Framework/YLErp.Core/Interest/SwapInterest.cs b/Framework/YLErp.Core/Interest/SwapInterest.cs deleted file mode 100644 index ddec05bf..00000000 --- a/Framework/YLErp.Core/Interest/SwapInterest.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using YLErp.Core.Interest; - -namespace YLErp.Derivatives.Interest; - -// ───────────────────────────────────────────────────────────────────────────── -// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法) -// -// 概念 唯一用词 与既有代码的对应 -// ─────────────────────────────────────────────────────────────────── -// 区间起点/终点 Start / End startDate / endDate -// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest -// 平仓 Unwind unwindPercent(既有字段 closePercent) -// 已实现利息 Realized realizedInterest(legacy 字段 consumedInterest) -// 待实现收益 Unrealized 预付金模式下的待实现收益余额 -// 计息基数 principal principal / dynomicPrincipal -// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays -// -// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。 -// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。 -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// 计息区间边界(算头 / 算尾)。 -/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。 -/// -public readonly struct AccrualBoundary -{ - /// 算头:含 startDate。 - public bool IncludeStart { get; } - - /// 算尾:含 endDate。 - public bool IncludeEnd { get; } - - private AccrualBoundary(bool includeStart, bool includeEnd) - => (IncludeStart, IncludeEnd) = (includeStart, includeEnd); - - /// 算头算尾 [start, end]。 - public static readonly AccrualBoundary Both = new(true, true); - - /// 算头不算尾 [start, end)。 - public static readonly AccrualBoundary StartOnly = new(true, false); - - /// 不算头算尾 (start, end]。 - public static readonly AccrualBoundary EndOnly = new(false, true); - - /// 不算头不算尾 (start, end)。 - public static readonly AccrualBoundary None = new(false, false); - - /// 由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。 - public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd); - - public override string ToString() - => $"{(IncludeStart ? "算头" : "不算头")}{(IncludeEnd ? "算尾" : "不算尾")}"; -} - -/// -/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSum;AccruedToday → TdInterestAmount。 -/// -public readonly struct InterestResult -{ - /// 区间累计应计利息。 - public decimal Accrued { get; } - - /// 末日(当日)应计利息。 - public decimal AccruedToday { get; } - - public InterestResult(decimal accrued, decimal accruedToday) - => (Accrued, AccruedToday) = (accrued, accruedToday); - - public static readonly InterestResult Zero = new(0m, 0m); - - public override string ToString() => $"Accrued={Accrued}, AccruedToday={AccruedToday}"; -} - -/// -/// 收益互换(TRS)利息腿计算——纯函数。 -/// -/// 层级关系:计息数学(单利/复利/连续复利)是通用金融原语,已抽到 -/// YLErp.Core.Interest,与互换无关,谁都能用)。 -/// 本类只负责 TRS 特有的会计态:每日先舍入再乘天数的对账口径、平仓缩放、 -/// 跨日滚动本金、预付金/授信模式——这些不是"利率数学",不应塞进通用原语。 -/// -/// 设计约束: -/// 1. 无副作用——不读写 flowEvent、不取利率、不连库、不碰任何共享可变状态; -/// 2. 同 input → 同 output,结果仅通过返回值流出; -/// 3. 正交轴(算头算尾 / 单利复利 / 平仓 / 待实现收益)各自独立,互不耦合; -/// 4. 调用方负责「取利率 + 构造日期区间 + 落库」,本类只算账。 -/// 由此,corp action 调整价格 / 数量时只需把新的 principal 与 rate 喂入,计息逻辑一行不动。 -/// -/// 领域口径:本系统利息腿是单边融资腿,任一时点只有一个生效利率(见 SwapDealService 的 -/// floateRate 单一入参),不存在 IRS 那种 fixedRate − floatingRate 轧差; -/// 权益腿盈亏与平仓费用属三腿汇总层,不在本类职责内。 -/// -/// TRS 的"复利"是离散重置日复利:按重置日切段,每段用 -/// 计息、段末把利息滚入本金——本质就是单利按段叠加,decimal 精度无损,无需 Pow/Exp -/// (见 )。所以本类不另立复利方法,计息只有一种,区别在于"是否滚动本金"。 -/// -/// 为何不复用 Qdp 的 IDayCount: -/// a. 语义——Qdp 的 DaysInPeriod = end − start 是写死的半开区间,只能表达四种算头算尾中的一种; -/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 且日息先 Round 再乘天数, -/// Round(P*r/365, 11) * n ≠ P*r*(n/365),与 Excel 对账口径不同; -/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让 YLErp.Core 反向依赖定价库。 -/// -public static class SwapInterest -{ - /// 默认舍入精度位数(历史值;生产融资腿与保证金腿均用 FundingLegPrecision=12)。 - public const int Precision = 11; - - /// 资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。 - /// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。 - public const int FundingLegPrecision = 12; - - /// 年化天数常量(合约字段存的是 int,故不用 enum)。 - public const int Act365 = 365; - - public const int Act360 = 360; - - /// 应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。 - public static int AccrualDays(DateTime startDate, DateTime endDate, AccrualBoundary boundary) - { - var s = boundary.IncludeStart ? startDate : startDate.AddDays(1); - var e = boundary.IncludeEnd ? endDate : endDate.AddDays(-1); - var days = (int)(e - s).TotalDays + 1; // 含两端 - return days < 0 ? 0 : days; - } - - /// 把 TRS 年化利率收敛为通用利率原语。 - /// TRS 计息按段均为单利——离散重置日复利靠"段末把利息滚入本金"实现,不引入 Compounded 闭式。 - public static InterestRate ToInterestRate(decimal annualRate) - => new(annualRate, Compounding.Simple); - - /// 单利:计息基数固定,每日利息相同,无逐日循环。 - public static InterestResult AccrueSimple( - AccrualContext ctx, - decimal principal, - decimal rate, - DateTime startDate, - DateTime endDate, - AccrualBoundary boundary) - { - var days = AccrualDays(startDate, endDate, boundary); - var daily = Round(principal * rate / ctx.AnnualDays, ctx.Precision); - return new InterestResult(Round(daily * days, ctx.Precision), daily); - } - - /// - /// 离散重置日复利(compounded-in-arrears):按重置日切段,段间把累计利息并入计息基数(滚动本金)。 - /// 每段计息即 得到的 (无逐日循环); - /// 重置日是唯一并本金的地方。复利与单利只有"是否滚动本金"这一个区别。 - /// - /// 此模型即 OIS / SOFR / FR007 的 compounded-in-arrears:每个子区间取一次定盘 rᵢ、增长因子 - /// 1 + rᵢ·yfᵢ,段末把 accrued 折进下一期本金——比闭式 - /// 更贴合 FR007 约定且 decimal 无损。注意:它不是 InterestRate 的 Compounded 闭式分支(TRS 下该分支为死路径)。 - /// - /// 每段可有独立利率(FR007 浮动逐段不同),由适配器按段取定盘后封装为 - /// 传入——取价永远在编排层,原语只吃一个数(与 QuantLib/Strata 同范)。 - /// 必须含一条 ResetDate ≤ startDate 的起始利率。 - /// - /// trace:经 发射 Start / ResetBefore·ResetAfter(利率切换时) / - /// Rollover(段末并本金) / End,完整记录"重置日前后、利率切换、本金增加前后"。纯函数保持无日志依赖。 - /// - /// 重置日 → 该段生效利率(段起点 = 重置日)。 - public static InterestResult AccrueCompoundInArrears( - AccrualContext ctx, - decimal principal, - IReadOnlyList<(DateTime ResetDate, decimal Rate)> resetSchedule, - DateTime startDate, - DateTime endDate, - AccrualBoundary boundary) - { - var trace = ctx.Trace; - trace?.MarkStart(startDate, endDate, boundary, ctx.AnnualDays, annualized: false); - - var basis = principal; - decimal accrued = 0m, accruedToday = 0m; - - var segEnds = (resetSchedule ?? Array.Empty<(DateTime, decimal)>()) - .Select(s => s.ResetDate) - .Where(d => d > startDate && d < endDate) - .OrderBy(d => d) - .Append(endDate) - .ToArray(); - - // 段起点生效利率:取"不晚于该段起点"的最近一次重置利率。 - decimal RateAt(DateTime segStart) - => (resetSchedule ?? Array.Empty<(DateTime, decimal)>()) - .Where(s => s.ResetDate <= segStart) - .OrderByDescending(s => s.ResetDate) - .Select(s => s.Rate) - .FirstOrDefault(); - - var segStart = startDate; - var segIncludeStart = boundary.IncludeStart; - var prevRate = RateAt(startDate); - - foreach (var segEnd in segEnds) - { - var segRate = RateAt(segStart); - var rateSwitched = segStart != startDate && segRate != prevRate; - if (rateSwitched) trace?.ResetBefore(segStart, prevRate, basis); - - var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd); - var seg = AccrueSimple(ctx, basis, segRate, segStart, segEnd, segBoundary); - - accrued += seg.Accrued; - accruedToday = seg.AccruedToday; - var newBasis = basis + seg.Accrued; // 仅在重置日并本金 - // 重置日本身不动本金:RESET↑ 的本金应是"重置边界基数"(basis),与 RESET↓ 一致; - // 段末并本金后的 newBasis 由下方的 ROLLOVER 单独表达,避免重复/误导。 - if (rateSwitched) trace?.ResetAfter(segStart, segRate, basis); - - trace?.Rollover(segEnd, seg.Accrued, newBasis); - basis = newBasis; - prevRate = segRate; - segStart = segEnd; - segIncludeStart = false; // 后续段不算头 - } - - var result = new InterestResult(accrued, accruedToday); - trace?.MarkEnd(result.Accrued, result.AccruedToday); - return result; - } - - /// - /// 固定利率复利便捷重载(每段同一 rate),向后兼容旧调用方。 - /// 内部把 resetDates 展平为"每段同率"的 schedule 后委托主方法。 - /// - public static InterestResult AccrueCompoundInArrears( - AccrualContext ctx, - decimal principal, - decimal rate, - DateTime startDate, - DateTime endDate, - AccrualBoundary boundary, - IReadOnlyList? resetDates = null) - { - var schedule = new List<(DateTime, decimal)> { (startDate, rate) }; - if (resetDates != null) - foreach (var d in resetDates) - if (d > startDate && d < endDate) - schedule.Add((d, rate)); - return AccrueCompoundInArrears(ctx, principal, schedule, startDate, endDate, boundary); - } - - /// - /// 平仓(Unwind)缩放——全仓唯一缩放点,物理上杜绝 unwindPercent 被重复相乘。 - /// 全平即 unwindPercent = 1,不另设方法。 - /// - /// 已实现 / 未实现边界:传入的 是平仓前仍「未实现(unrealized)」的 - /// 累计应计利息;本方法按比例缩放后返回「平仓后剩余未实现」部分,并扣除历史累计「已实现(realized)」 - /// 的 。被平仓比例 unwindPercent 对应的那一份 accrued, - /// 即在此刻「实现(realized)」,由调用方记入 realizedInterest。 - /// - /// 平仓前累计应计利息(未实现)。 - /// - /// 平仓比例(0~1,实为 ratio 非百分数)。 - /// 对应既有字段 closePercent;分母口径必须与传入 所依据的持仓数量一致—— - /// 是「本次计算依据的持仓」而非「初始建仓」,历史缺陷正来自这个歧义。 - /// - /// 已实现利息累计(legacy 字段 consumedInterest):历史各次 unwind 已确认、应从剩余未实现中扣除的部分。 - /// 舍入精度。⚠️ 默认 11(Precision),资金腿务必显式传 =12。 - public static InterestResult ApplyUnwind( - InterestResult accrued, - decimal unwindPercent, - decimal realizedInterest = 0m, - int precision = Precision) - { - var remaining = 1m - unwindPercent; - return new InterestResult( - Round(accrued.Accrued * remaining - realizedInterest, precision), - Round(accrued.AccruedToday * remaining, precision)); - } - - /// 待实现收益余额滚动(预付金 / 授信模式)。 - /// 上期待实现收益余额。 - /// 本期新增。 - /// 本期 unwind 应扣减(即本期实现的份额)。 - public static decimal AccrueUnrealized( - decimal openingUnrealized, - decimal todayIncome, - decimal unwindDeduction, - int precision = Precision) - => Round(openingUnrealized + todayIncome - unwindDeduction, precision); - - /// 统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。 - public static decimal Round(decimal value, int precision) - => Math.Round(value, precision, MidpointRounding.AwayFromZero); -} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs index 625d2254..6a1d8984 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs @@ -7,8 +7,6 @@ using YLErp.DBModels; using YLErp.DBModels.Enums; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Accrual; -using YLErp.Derivatives.Interest; -using YLErp.Core.Interest; namespace UnitTestProject.Modules.SwapModule.Accrual { diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs index 305fe802..2f6aed6e 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs @@ -1,6 +1,5 @@ using Newtonsoft.Json; using YLErp; -using YLErp.Derivatives.Interest; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Accrual; diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs b/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs deleted file mode 100644 index 816be349..00000000 --- a/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Text.RegularExpressions; -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - -namespace UnitTestProject.Modules.SwapModule.Accrual -{ - /// - /// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。 - /// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。 - /// - /// 与原草稿的关键区别:本版直接通过 AccrualTrace 断言不变量。 - /// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 / - /// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的, - /// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。 - /// - [TestClass] - public class SwapInterest_CompoundInArrears_RolloverTimingTests - { - private const int FundingLegPrecision = 12; - private const int AnnualDays = 365; - - /// - /// 场景:14天窗口,第8天(01-08)重置一次,利率恒定 3.65%(日利率 0.01%)。 - /// 验证: - /// (1) 总利息 = 1400.49(第1期700 + 第2期700.49); - /// (2) ROLLOVER 仅发生在重置日(01-08)与窗口终点(01-15),非重置日(如01-03)绝不滚存; - /// (3) 重置日 ROLLOVER 的 newBasis = 原始本金 + 前7天利息 = 1,000,700, - /// 证明第1段计息基数恒为原始本金、段内未提前资本化。 - /// - [TestMethod] - public void InterestPrincipal_ShouldRollOnlyOnResetDays_NotOnNonResetDays() - { - var startDate = new DateTime(2026, 1, 1); - var endDate = new DateTime(2026, 1, 15); - - var principal = 1_000_000m; - var rate = 0.0365m; - var resetDates = new List { new DateTime(2026, 1, 8) }; - var trace = new AccrualTrace(); - var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); - - var result = SwapInterest.AccrueCompoundInArrears( - ctx, - principal, - rate, - startDate, - endDate, - AccrualBoundary.Both, - resetDates); - - Assert.AreEqual(1400.49m, Math.Round(result.Accrued, 2)); - - var rolloverDates = trace.Entries - .Where(e => e.Step == AccrualTraceEvent.Rollover) - .Select(e => e.Date) - .ToList(); - - var allowed = resetDates.Concat(new[] { endDate }).OrderBy(d => d).ToList(); - CollectionAssert.AreEqual(allowed, rolloverDates.OrderBy(d => d).ToList()); - - Assert.IsFalse(rolloverDates.Contains(new DateTime(2026, 1, 3)), - "非重置日发生了本金滚存,违反确认书规定"); - - var resetRollover = trace.Entries - .First(e => e.Step == AccrualTraceEvent.Rollover && e.Date == new DateTime(2026, 1, 8)); - var newBasis = ParseNewBasis(resetRollover.Line); - Assert.AreEqual(principal + 700m, newBasis, - "重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化"); - } - - /// - /// 极端场景:startDate = endDate(1天),无重置日。 - /// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。 - /// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。 - /// - [TestMethod] - public void SingleDay_ShouldNotRollInterest_NoResetDay() - { - var date = new DateTime(2026, 1, 1); - var principal = 1_000_000m; - var rate = 0.0365m; - var trace = new AccrualTrace(); - var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); - - var result = SwapInterest.AccrueCompoundInArrears( - ctx, - principal, - rate, - date, - date, - AccrualBoundary.Both); - - Assert.AreEqual(100m, Math.Round(result.Accrued, 2)); - - var rolloverDates = trace.Entries - .Where(e => e.Step == AccrualTraceEvent.Rollover) - .Select(e => e.Date) - .ToList(); - CollectionAssert.AreEqual(new[] { date }, rolloverDates.ToArray()); - } - - /// - /// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。 - /// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日, - /// 期望利息 = 本金 × 日利率 × 6 = 600。 - /// - [TestMethod] - public void WithinPeriod_NoRollover_ShouldMatchSimpleInterest() - { - var startDate = new DateTime(2026, 1, 1); - var endDate = new DateTime(2026, 1, 6); - var principal = 1_000_000m; - var rate = 0.0365m; - var trace = new AccrualTrace(); - var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); - - var result = SwapInterest.AccrueCompoundInArrears( - ctx, - principal, - rate, - startDate, - endDate, - AccrualBoundary.Both); - - // 计息天数必须用边界感知的 AccrualDays,不能拿 (end-start).Days(会少算1天) - var days = SwapInterest.AccrualDays(startDate, endDate, AccrualBoundary.Both); // = 6 - var expected = Math.Round(principal * rate * days / AnnualDays, FundingLegPrecision, MidpointRounding.AwayFromZero); - Assert.AreEqual(expected, Math.Round(result.Accrued, 10)); - - var rolloverDates = trace.Entries - .Where(e => e.Step == AccrualTraceEvent.Rollover) - .Select(e => e.Date) - .ToList(); - CollectionAssert.AreEqual(new[] { endDate }, rolloverDates.ToArray()); - } - - private static decimal ParseNewBasis(string line) - { - var m = Regex.Match(line, @"newBasis=([0-9.]+)"); - Assert.IsTrue(m.Success, $"ROLLOVER 行缺少 newBasis:{line}"); - return decimal.Parse(m.Groups[1].Value); - } - } -} diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs index 34740fc2..2ff0f5ad 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestShadowTest.cs @@ -8,7 +8,6 @@ using YLErp.DBModels.Enums; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.Accrual; using YLErp.Modules.SwapModule.Margin; -using YLErp.Derivatives.Interest; namespace UnitTestProject.Modules.SwapModule.Margin { diff --git a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md index 1ddf3913..669e64ff 100644 --- a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md +++ b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md @@ -62,6 +62,14 @@ SwapModule/ │ ├── DirectionRatio 方向因子(LongShort + ReceivePay) │ └── PositionValueCalc 持仓价值汇总(利息端 + 浮动端) │ +├── Accrual/ 计息(生产实现,自洽域) +│ ├── InterestMath 共用数学:Round/AccrualDays/FundingLegPrecision + AccrualBoundary/InterestResult +│ ├── SimpleInterestAccrual 单利纯函数(AccrueEod 单日 + AccruePeriod 多日) +│ ├── CompoundInterestAccrual 复利纯函数(EodBasis/AccrueEod/AccruePeriod) +│ ├── AccrualPolicy 计息政策(算头算尾/单复利/重置周期/年化) +│ ├── AccrualTrace 计息 trace 收集器(SwapCalcTrace.Write 常驻落盘) +│ └── FundingLegRate all-in 利率值对象 +│ ├── SwapDealService.cs 盘中平仓/互换主逻辑 ├── SwapEodPositionService.cs EOD 日终归档主逻辑 ├── SwapDealIndexFixer.cs SwapDealService 专用取价器(委托 TryGetFloatRate) @@ -72,12 +80,16 @@ SwapModule/ ``` Interest/ -├── SwapInterest.cs 纯函数库(AccrueSimple/AccrueCompound/ApplyUnwind) ├── IIndexFixer.cs 取价接口 -├── IndexFixerBase.cs 取价日计算工具 -└── Fr007IndexFixer.cs FR007 取价生产实现(调 EodPriceQueryService) +└── IndexFixerBase.cs 取价日计算工具 ``` +> 注:① `Fr007IndexFixer.cs`(FR007 取价生产实现)在 SwapModule 下,不在本目录。 +> ② 2026-08 计息类型(InterestMath/AccrualBoundary/InterestResult/AccrualTrace)已整体迁至 SwapModule/Accrual/, +> Core 不再持有计息实现。原 Core 层 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/ +> AccrueUnrealized/ToInterestRate)与 AccrualContext/InterestRate 从未接线(生产走 Accrual/ 目录),作为孤儿死代码删除—— +> 其舍入/rollover 口径与生产实现已分叉,若将来重建须先补对账测试,勿凭记忆复原。 + ## InterestModeEnum(显式赋值,DB 契约) ``` diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs index 9f7e4ec4..324a373b 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualPolicy.cs @@ -1,5 +1,3 @@ -using YLErp.Derivatives.Interest; - namespace YLErp.Modules.SwapModule.Accrual; /// @@ -11,7 +9,7 @@ namespace YLErp.Modules.SwapModule.Accrual; /// public sealed class AccrualPolicy { - /// 算头算尾约定(复用 SwapInterest 已有的 AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。 + /// 算头算尾约定(AccrualBoundary,物理上杜绝 calcFirst/calcLast 传反)。 public AccrualBoundary Convention { get; } /// 是否复利(利滚利)。来自 DB 的 InterestTypeEnum;单利=false,复利=true。 diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs deleted file mode 100644 index 3ba2c6fb..00000000 --- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualState.cs +++ /dev/null @@ -1,49 +0,0 @@ -using YLErp.DBModels; - -namespace YLErp.Modules.SwapModule.Accrual; - -/// -/// 融资腿逐日计息的跨日状态(不可变值对象)。 -/// 这是"待实现利息"在日间滚动的快照,区别于已落库的 swap_flow_event。 -/// -/// 旧字段 → 领域命名映射(DB 列不可改,仅在边界处适配;本类内部一律用下列自描述名): -/// -/// TdInterestPrincipal逐日滚动的计息本金 → -/// InterestIncomeSum累计待实现利息 → -/// consumedInterest历史已实现利息(legacy) → -/// ValueDate快照截至日 → (EOD 续接起算日,Bug C / 5-11 跳过需据此判断从哪天接续)。 -/// -/// -public readonly struct AccrualState -{ - /// 用于计算当日利息的计息本金。单利=名义本金基数;复利=本金+累计利息。 - public decimal AccrualPrincipal { get; } - - /// 累计待实现(未平仓)利息。 - public decimal UnrealizedInterest { get; } - - /// 历史各次平仓已确认的已实现利息,从剩余待实现中扣除。 - public decimal RealizedInterest { get; } - - /// 快照截至日(来自 eod_swap_position.ValueDate)。编排层据此判断计息区间起点,避免 5-11 等"跳过日"误重算。 - public DateTime ValueDate { get; } - - public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest, DateTime valueDate) - => (AccrualPrincipal, UnrealizedInterest, RealizedInterest, ValueDate) = (accrualPrincipal, unrealizedInterest, realizedInterest, valueDate); - - /// 向后兼容:未携带快照日期时(如纯内存构造)用默认日。 - public AccrualState(decimal accrualPrincipal, decimal unrealizedInterest, decimal realizedInterest) - : this(accrualPrincipal, unrealizedInterest, realizedInterest, default) { } - - /// 空状态(新开仓首个计息日之前)。 - public static readonly AccrualState Zero = new(0m, 0m, 0m); - - /// - /// 从上一日日终归档 适配(边界适配:DB 列名 → 领域名)。 - /// 仅映射计息状态;名义本金基数 / 平仓比例 / 已实现利息等由调用方另行传入。 - /// - public static AccrualState FromPreviousEod(eod_swap_position previousEod) - => previousEod == null || previousEod.id == 0 - ? Zero - : new AccrualState(previousEod.TdInterestPrincipal, previousEod.InterestIncomeSum, 0m, previousEod.ValueDate); -} diff --git a/Framework/YLErp.Core/Interest/AccrualTrace.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs similarity index 94% rename from Framework/YLErp.Core/Interest/AccrualTrace.cs rename to YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs index 108b2692..271a0bc4 100644 --- a/Framework/YLErp.Core/Interest/AccrualTrace.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs @@ -1,14 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using YLErp.Derivatives.Interest; - -namespace YLErp.Core.Interest; +namespace YLErp.Modules.SwapModule.Accrual; /// -/// 计息过程追踪收集器(值对象,非日志)。 +/// 计息过程追踪收集器(值对象,非日志)。2026-08 自 Core 层(YLErp.Core.Interest)迁入 DAL, +/// 与 Simple/CompoundInterestAccrual、AccrualBoundary 同处一域,Core 不再持有计息类型。 /// -/// 为什么是收集器而不是日志调用:计息数学(SwapInterest / FundingLegAccrual)必须保持纯函数、 +/// 为什么是收集器而不是日志调用:计息数学(Simple/CompoundInterestAccrual)必须保持纯函数、 /// 可单测、不依赖 NLog;但按工程铁律,关键路径日志须无条件常驻落盘(出问题时事后翻日志定位,不能依赖开关)。 /// 折中:纯函数把"发生了什么"记录为结构化条目写入本收集器,由适配器(IO 边界)统一经 /// SwapCalcTrace.Write 常驻落盘。落盘职责归一处,计息代码零日志依赖、保持干净。 diff --git a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs index 9418aa46..eddbb07a 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs @@ -1,6 +1,3 @@ -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - namespace YLErp.Modules.SwapModule.Accrual; /// @@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual; /// public static class CompoundInterestAccrual { - private const int Precision = SwapInterest.FundingLegPrecision; + private const int Precision = InterestMath.FundingLegPrecision; /// 复利日终计息基数(单一真相源,纯函数与调用方共用): /// 重置日 = notional + 累计利息×剩余比例(利息并入本金);非重置日 = priorNotional(昨日滚动基数)。 @@ -52,8 +49,8 @@ public static class CompoundInterestAccrual var totalAccrued = priorAccrued * unwindFraction + dayInterest; var result = new InterestResult( - SwapInterest.Round(totalAccrued, Precision), - SwapInterest.Round(tdInterest, Precision)); + InterestMath.Round(totalAccrued, Precision), + InterestMath.Round(tdInterest, Precision)); trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); trace?.MarkEnd(result.Accrued, result.AccruedToday); @@ -107,7 +104,7 @@ public static class CompoundInterestAccrual var segIncludeStart = (si == 0) ? boundary.IncludeStart : true; var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false; - var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd, + var days = InterestMath.AccrualDays(segmentRates[si].StartDate, segEnd, AccrualBoundary.Of(segIncludeStart, segIncludeEnd)); if (days <= 0) continue; @@ -124,8 +121,8 @@ public static class CompoundInterestAccrual accrued -= realizedInterest * unwindFraction; var result = new InterestResult( - SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accrued, Precision)); + InterestMath.Round(accrued, Precision), + InterestMath.Round(accrued, Precision)); trace?.MarkEnd(result.Accrued, result.AccruedToday); return result; } diff --git a/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs b/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs new file mode 100644 index 00000000..54b5de29 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Accrual/InterestMath.cs @@ -0,0 +1,104 @@ +namespace YLErp.Modules.SwapModule.Accrual; + +// ───────────────────────────────────────────────────────────────────────────── +// 词汇表(本文件只允许出现下列用词,同一概念不得出现第二种叫法) +// +// 概念 唯一用词 与既有代码的对应 +// ─────────────────────────────────────────────────────────────────── +// 区间起点/终点 Start / End startDate / endDate +// 计息 Accrue CalcDailySimpleInterest / CalcDailyCompoundInterest +// 平仓 Unwind unwindPercent(既有字段 closePercent) +// 已实现利息 Realized realizedInterest(legacy 字段 consumedInterest) +// 待实现收益 Unrealized 预付金模式下的待实现收益余额 +// 计息基数 principal principal / dynomicPrincipal +// 年化天数 annualDays tradeExtend.ExtendObj.AnnualDays +// +// 入参一律沿用既有代码的字段名,调用点两边读起来同名,不产生心智翻译成本。 +// 出参改用自描述名(Accrued / AccruedToday),因为 "Td" 对新读者是黑话。 +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 计息区间边界(算头 / 算尾)。 +/// 用具名值取代两个相邻 bool,物理上杜绝 calcFirst / calcLast 传反这一类历史缺陷。 +/// +public readonly struct AccrualBoundary +{ + /// 算头:含 startDate。 + public bool IncludeStart { get; } + + /// 算尾:含 endDate。 + public bool IncludeEnd { get; } + + private AccrualBoundary(bool includeStart, bool includeEnd) + => (IncludeStart, IncludeEnd) = (includeStart, includeEnd); + + /// 算头算尾 [start, end]。 + public static readonly AccrualBoundary Both = new(true, true); + + /// 算头不算尾 [start, end)。 + public static readonly AccrualBoundary StartOnly = new(true, false); + + /// 不算头算尾 (start, end]。 + public static readonly AccrualBoundary EndOnly = new(false, true); + + /// 不算头不算尾 (start, end)。 + public static readonly AccrualBoundary None = new(false, false); + + /// 由既有 calcFirst / calcLast 布尔对构造,供旧调用方渐进迁移。 + public static AccrualBoundary Of(bool includeStart, bool includeEnd) => new(includeStart, includeEnd); + + public override string ToString() + => $"{(IncludeStart ? "算头" : "不算头")}{(IncludeEnd ? "算尾" : "不算尾")}"; +} + +/// +/// 计息结果。Accrued → 记账字段 InterestAmount / InterestProfitSum;AccruedToday → TdInterestAmount。 +/// +public readonly struct InterestResult +{ + /// 区间累计应计利息。 + public decimal Accrued { get; } + + /// 末日(当日)应计利息。 + public decimal AccruedToday { get; } + + public InterestResult(decimal accrued, decimal accruedToday) + => (Accrued, AccruedToday) = (accrued, accruedToday); + + public static readonly InterestResult Zero = new(0m, 0m); + + public override string ToString() => $"Accrued={Accrued}, AccruedToday={AccruedToday}"; +} + +/// +/// 利息腿共用数学工具:舍入、应计天数、精度常量。 +/// +/// 沿革:2026-08 自 Core 层 SwapInterest 迁入 DAL(生产消费面整体搬家)。 +/// 原 SwapInterest 的算法方法(AccrueSimple/AccrueCompoundInArrears/ApplyUnwind/AccrueUnrealized) +/// 与 AccrualContext/InterestRate 始终未接线(生产计息走本目录 Simple/CompoundInterestAccrual, +/// 两者舍入与 rollover 口径已分叉),作为孤儿死代码删除——接线前须先补对账,勿凭记忆重建。 +/// +/// 为何不复用 Qdp 的 IDayCount: +/// a. 语义——Qdp 的 DaysInPeriod = end − start 是写死的半开区间,只能表达四种算头算尾中的一种; +/// b. 精度——Qdp 返回 double 年化系数,本系统 decimal 对账; +/// c. 依赖方向——Qdp 用自有 Date 类型,引入会让本模块反向依赖定价库。 +/// +public static class InterestMath +{ + /// 资金腿与保证金腿的生产计息精度(落库/对账均以 12 位为准)。 + /// 提升至公共常量,消除 SwapDealService 与 SimpleInterestAccrual 的重复定义。 + public const int FundingLegPrecision = 12; + + /// 应计天数。边界规则由日期区间表达,计息函数内不再出现 flag 分支。 + public static int AccrualDays(DateTime startDate, DateTime endDate, AccrualBoundary boundary) + { + var s = boundary.IncludeStart ? startDate : startDate.AddDays(1); + var e = boundary.IncludeEnd ? endDate : endDate.AddDays(-1); + var days = (int)(e - s).TotalDays + 1; // 含两端 + return days < 0 ? 0 : days; + } + + /// 统一舍入:MidpointRounding.AwayFromZero。所有计息路径收口到此处,避免散落的 Math.Round 不一致。 + public static decimal Round(decimal value, int precision) + => Math.Round(value, precision, MidpointRounding.AwayFromZero); +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs index fb1378ab..ac184645 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/SimpleInterestAccrual.cs @@ -1,6 +1,3 @@ -using YLErp.Core.Interest; -using YLErp.Derivatives.Interest; - namespace YLErp.Modules.SwapModule.Accrual; /// @@ -9,7 +6,7 @@ namespace YLErp.Modules.SwapModule.Accrual; /// public static class SimpleInterestAccrual { - private const int Precision = SwapInterest.FundingLegPrecision; + private const int Precision = InterestMath.FundingLegPrecision; /// /// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。 @@ -38,8 +35,8 @@ public static class SimpleInterestAccrual var totalAccrued = priorAccrued + dayInterest; var result = new InterestResult( - SwapInterest.Round(totalAccrued, Precision), - SwapInterest.Round(tdInterest, Precision)); + InterestMath.Round(totalAccrued, Precision), + InterestMath.Round(tdInterest, Precision)); trace?.Day(0, eodDate, allInRate, displayBasis, dayInterest, totalAccrued); trace?.MarkEnd(result.Accrued, result.AccruedToday); @@ -85,7 +82,7 @@ public static class SimpleInterestAccrual var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true; var isLastSegment = si == segmentRates.Count - 1; var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd); - var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary); + var days = InterestMath.AccrualDays(effectiveStart, segEnd, segBoundary); if (days <= 0) { segStart = segEnd; continue; } var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; @@ -98,8 +95,8 @@ public static class SimpleInterestAccrual } var result = new InterestResult( - SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accruedUnscaled, Precision)); + InterestMath.Round(accrued, Precision), + InterestMath.Round(accruedUnscaled, Precision)); trace?.MarkEnd(result.Accrued, result.AccruedToday); return result; } diff --git a/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs index f6e1a1ec..40bc9571 100644 --- a/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs +++ b/YLErpDAL/Modules/SwapModule/SwapCalcTrace.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; using System.Text; -using YLErp.Core.Interest; -using YLErp.Helpers; +using YLErp.Modules.SwapModule.Accrual; namespace YLErp.Modules.SwapModule { diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 7990a964..7e69e78b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -3,7 +3,6 @@ using Newtonsoft.Json; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.DBModels.Enums; -using YLErp.Core.Interest; using YLErp.Derivatives.Interest; using YLErp.Helpers; using YLErp.Modules.DataProviderModule; @@ -50,8 +49,8 @@ namespace YLErp.Modules.SwapModule return SaveSwapDealInternal(unwindData, eventType, clientCashId, eventResason, approve); } - // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 SwapInterest.FundingLegPrecision,消除重复定义。 - private const int InterestCalculationPrecision = SwapInterest.FundingLegPrecision; + // 待实现利息会进入 decimal(30,12) 日终快照;精度常量统一引用 InterestMath.FundingLegPrecision,消除重复定义。 + private const int InterestCalculationPrecision = InterestMath.FundingLegPrecision; // 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。 private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason) @@ -1456,7 +1455,7 @@ namespace YLErp.Modules.SwapModule SwapCalcTrace.Write(interestTrace); // flowEvent.InterestPrincipal:当日计息基数(已按平仓比例缩放)——下游 EOD 用它播种次日 TdInterestPrincipal。 - // 复用 CompoundEodBasis 单一真相源(与 AccrueCompoundEod 内部同一公式)。 + // 复用 CompoundEodBasis 单一真相源(与 CompoundInterestAccrual.AccrueEod 内部同一公式,见其 EodBasis 调用)。 flowEvent.InterestPrincipal = CompoundInterestAccrual.EodBasis( isResetDay, posiPrincipal, preEodPosition.InterestProfitSum, remainingFraction, preEodPosition.TdInterestPrincipal) * closePercent; diff --git a/corp-action-refactor-proposal.md b/corp-action-refactor-proposal.md index b21d529b..6ec51e4e 100644 --- a/corp-action-refactor-proposal.md +++ b/corp-action-refactor-proposal.md @@ -99,7 +99,7 @@ curretEod.PosiQuantity = qty < 0 ? 0 : Math.Abs(qty); | **数量递推** | `SwapEodPositionService.cs:1897`(qty 递推)、`:1723`/`:1905`(无事件日结转)、`:1631`(首次归档) | 需新增「公司行为数量」第三来源项 | `SwapEodPositionService.cs` | | **`TdChangedQty`** | 定义 `EodSwapPosition.cs:300`(DisplayName "当日公司行为数量");唯一赋值 `SwapEodPositionService.cs:1650`(恒=0) | 挂进 :1897 递推式(与 `TdCloseQty`:1942 对称),否则与 `PosiQuantity` 永久不自洽 | `SwapEodPositionService.cs` | | **成本均价** | `SwapEodPositionService.cs:1926-1936`(加权重算,TRS 无 `CostPrice` 字段,等价字段 `PosiGrossPrice`/`PosiNetPrice`) | 送股无成交金额(分子+0、分母增)→ 走 `:1912 else if` 分支价不摊薄,污染盯市;配股有现金需加 `RationedSharesAmount×Price` | `SwapEodPositionService.cs:1912-1937` | -| **计息基准** | `SwapDealService.cs:893 CalcNotionalByMode`(五种模式分流)、`:1372` `dynomicPrincipal`、`:2384 InterestPrincipalFix` 仅平仓递减 | `posiLong/Short` 经 `PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` | +| **计息基准** | mode 分流已重构为 `FundingLegs/FundingLegStrategyFactory`(原 `SwapDealService CalcNotionalByMode` 已删;`dynomicPrincipal` 亦随重构消失)、`InterestPrincipalFix` 仅平仓递减 | `posiLong/Short` 经 `PosiNotionalValue` 可自动跟随;**`InterestPrincipalFix` 是独立存量,与数量解耦**,配股缴款需新写入点 | `SwapDealService.cs` + `FundingLegs/` | | **盯市盈亏** | `SwapEodPositionService.cs:1657/1730/1815/2019`(4 份同构副本 `PosiMtmPnL`)、`:1713 GetSwapValuationPrice`(取除权后价) | 数量突变日若 `PosiQuantity`/`PosiGrossPrice` 未同步除权 → 虚假巨亏;**4 处副本必须一致改** | `SwapEodPositionService.cs` | | **数据源** | `DividendService.cs:752 GetPositionAmount`(现成 `amount*(1+GiveShareAmount/10)` 送股调整)、`:730 GetRatio`(现成除权价公式) | SwapModule 未复用,需建调用边 | 新增 SwapModule→DividendService 调用 | From 01447ffe0404d20d656d4da06b73e1f95f2eb41d Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 15:39:44 +0800 Subject: [PATCH 17/43] =?UTF-8?q?refactor(swap):=20GetInterests=20?= =?UTF-8?q?=E4=BC=A0=E5=8F=82=E8=AF=AD=E4=B9=89=E6=98=BE=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=9B=98=E4=B8=AD/EOD=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E5=90=8E=E6=94=B6=E7=9B=98=E6=8B=86=E5=8F=8C=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:GetInterests 同名参数在两类调用方下语义相反,约定只存在于注释(2035e1df 固化): 盘中(:489链) EOD平仓后收盘(:1311链) posiNotionalValue 平仓前剩余本金 vs 平仓后剩余本金 closePercent 实际平仓比例(B) vs 恒1(全额结息) 均走 settment:false 盘中重放算法。6fdc7d80 修的错账即两语义混用产物, mode2 无条件覆盖/mode9 全平兜底是粘合补丁。 改动(纯机械,零行为变化): - SwapDealService 新增 GetIntradayUnwindInterests(preCloseNotional/closedNotional/ closePercentRemaining 具名),GetUnwindInterests 切换调用;needPrice/grossPrice 为 GetInterests 死参数(体内零消费),新入口不再暴露 - SwapEodPositionService 新增虚接缝 CalcEodPostCloseSettleInterests (remainingNotionalAfterClose/closedNotional/恒1),默认实现经 CalcSwapInterests 转发——既有测试替身对该接缝的拦截不变;SaveAutoEodWithCloseInterestPosition 切换调用 - 原 GetInterests/CalcSwapInterests 签名与行为不动(测试直调兼容);EOD 增量路径 (:1148/:1563, settment:true, closePosi=posi 同值) 语义自洽,本次不动 验证:全量 899 测试 145失败/742通过/12跳过——与 e2431e9d 基线逐位一致,零回归。 --- .../Modules/SwapModule/SwapDealService.cs | 39 ++++++++++++++++++- .../SwapModule/SwapEodPositionService.cs | 35 +++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 7e69e78b..b22c70e7 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -486,7 +486,10 @@ namespace YLErp.Modules.SwapModule && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; - interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList); + // 显式入口(语义见 GetIntradayUnwindInterests 注释):平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放 + interests = GetIntradayUnwindInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, + stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, + closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList); return interests; } @@ -616,6 +619,40 @@ namespace YLErp.Modules.SwapModule /// /// /// + /// + /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。 + /// + /// 语义契约(与 EOD 平仓后收盘的 CalcEodPostCloseSettleInterests 相反,勿混用): + /// preCloseNotional = 平仓【前】实时剩余本金(原 GetUnwindInterests 的 stockEqvNotional); + /// closedNotional = 本次实际平掉本金(= preCloseNotional × closePercentRemaining); + /// closePercentRemaining = 平仓比例,B 语义【占剩余】(前端传 A 占期初,须先经 ToRemainingClosePercent 转换); + /// 计息走 CalcUnwindInterest 全区间重放(orginPv 参与保证金腿差分)。 + /// + /// needPrice/grossPrice 为 GetInterests 的历史死参数(方法体内无消费),本入口不再暴露。 + /// + public List GetIntradayUnwindInterests( + trade td, + trade_extend tradeExtend, + DateTime valueDate, + DateTime unwindDate, + List eodPositions, + List positions, + decimal preCloseNotional, + decimal preCloseLongNotional, + decimal preCloseShortNotional, + decimal closedNotional, + decimal closePercentRemaining, + int eventType, + bool tdClose, + decimal orginPv, + bool add, + bool newCalcLast, + List closeList) + => GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + preCloseNotional, preCloseLongNotional, preCloseShortNotional, closedNotional, + closePercentRemaining, eventType, tdClose, needPrice: false, grossPrice: 0m, + orginPv, add, settment: false, newCalcLast, closeList); + public List GetInterests( trade td, trade_extend tradeExtend, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index be097caf..795b5829 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -102,6 +102,33 @@ namespace YLErp.Modules.SwapModule grossPrice, orginPv, add, settment, newCalcLast, closeList); } + /// + /// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调 + /// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。 + /// + /// 语义契约(与盘中 SwapDealService.GetIntradayUnwindInterests 相反,勿混用): + /// remainingNotionalAfterClose = 平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位); + /// closedNotional = 本次实际平掉本金(GetInterests.closePosiNotionalValue 形参位); + /// 结息比例恒 1(本次事件全额结息)。该组合会触发 GetInterests 内 mode2 无条件覆盖 / + /// mode9 全平兜底(见其"根因位置"注释,勿删)。 + /// 计息走 CalcUnwindInterest 全区间重放(settment:false)。 + /// + /// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。 + /// + protected virtual List CalcEodPostCloseSettleInterests( + trade td, trade_extend tradeExtend, + DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal remainingNotionalAfterClose, decimal remainingLongNotional, decimal remainingShortNotional, + decimal closedNotional, + int eventType, bool tdClose, + decimal grossPrice, decimal orginPv, + bool add, bool newCalcLast) + => CalcSwapInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + remainingNotionalAfterClose, remainingLongNotional, remainingShortNotional, + closedNotional, 1m, eventType, tdClose, needPrice: true, grossPrice, orginPv, + add, settment: false, newCalcLast, closeList: null); + // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) /// 查找交易扩展(生产: DbContext.trade_extend;测试: 内存字典) @@ -1306,9 +1333,11 @@ namespace YLErp.Modules.SwapModule List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true; - // 此处 closePercent=1 表示 EOD 计算本次事件时走全额结息;它不是 closeNational / oriPosiNotionalValue。 - // 与上方“收盘后剩余本金”同时传入会触发共享计息器的模式2/9本金修正,见 GetInterests。 - var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast); + // 显式入口(语义见 CalcEodPostCloseSettleInterests 注释):平仓后剩余本金 + 实际平掉额 + 恒1全额结息。 + // 该组合会触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 + var interests = CalcEodPostCloseSettleInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, + posiNotionalValue, posiLongNotional, posiShortNational, closeNational, + eventType, tdClose: false, grossPrice, orginPv, add: true, newCalcLast: autoSwap || calcLast); // TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。 // interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。 // manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。 From b01b485ee4e7451d20d322d425a321c9c298f20f Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 15:52:52 +0800 Subject: [PATCH 18/43] =?UTF-8?q?test(swap):=20GetInterests=20=E5=8F=8C?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E8=AF=AD=E4=B9=89=E5=AD=97=E7=AC=A6=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E2=80=94=E2=80=94=E5=B9=B6=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E5=A4=8D=E5=88=A9=E5=8F=A3=E5=BE=84=E5=88=86?= =?UTF-8?q?=E6=AD=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 钉住三组现状(Step3 特判降级的前置回归网): - 复利×mode2×部分平仓30%:盘中=0.036164835616(closePosi=平掉额300 全程重放); EOD=0.059041913305(preEod待实现0.05 + 平掉额末段增量0.009042,closePercent==1 分支) ⚠️ 两值不等=同一经济事件两种结息额的口径分歧,已留档待业务裁决(勿当既定正确) - 单利×mode2:双入口数值留档(Console),断言非零 - 复利×mode9 全平(posi=0):兜底覆盖生效,结息额非零(兜底钉子) 基建复用 GetInterestsUnitTest_T0 口径(T+0、4/27起息、11算头算尾、FR007内存取价stub)。 --- .../GetInterestsEntrySemanticsTest.cs | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs new file mode 100644 index 00000000..7cf471e0 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -0,0 +1,220 @@ +using Newtonsoft.Json; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。 + /// + /// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与 + /// CalcEodPostCloseSettleInterests(EOD平仓后收盘:平仓后剩余×恒1)是同一经济事件 + /// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。 + /// 本测试钉死当前行为,使后续特判降级/语义重构有回归网: + /// ① 复利×mode2:closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金—— + /// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等; + /// ② 单利×mode2:CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent—— + /// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释); + /// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。 + /// + /// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+0,4/27起息,"11"算头算尾)。 + /// + [TestClass] + public class GetInterestsEntrySemanticsTest + { + private const decimal Principal = 1000m; + private const decimal FixedRate = 0.01m; + private const decimal FloatRate = 0.001m; + private const int AnnualDays = 365; + private const int ResetPeriod = 3; + + private static readonly DateTime TradeDate = new(2026, 4, 27); + private static readonly DateTime StartDate = new(2026, 4, 27); + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + private static readonly DateTime UnwindDate = new(2026, 4, 30); + + // 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700 + private const decimal PreClose = 1000m; + private const decimal Closed = 300m; + private const decimal Remaining = 700m; + private const decimal ClosePercent = 0.3m; + + #region Stub(浮动利率内存取价,与 T0 同款) + + private sealed class StubSwapDealService : SwapDealService + { + private readonly IReadOnlyDictionary _floatRates; + public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary floatRates) : base(optUser) + { + _floatRates = floatRates; + } + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; } + if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true; + rate = 0; + return false; + } + } + + private static SwapDealService CreateService() => new StubSwapDealService( + new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest), + new Dictionary + { + [new DateTime(2026, 4, 27)] = (double)FloatRate, + [new DateTime(2026, 4, 28)] = (double)FloatRate, + [new DateTime(2026, 4, 29)] = (double)FloatRate, + [new DateTime(2026, 4, 30)] = (double)FloatRate, + }); + + #endregion + + #region 数据构建(T0 口径) + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "11", // 算头算尾 + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998, + TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false) + { + var intervalModels = new List + { + new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 } + }; + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode, + InterestRateDefault = FixedRate, InterestPrincipalFix = Principal, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)interestType, + IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0, + FloatRateUnderlyingCode = floating ? "FR007" : null, + InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) + }; + } + + private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal) + => new() + { + id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29), + ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal, + PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum + }; + + #endregion + + /// + /// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。 + /// + /// 实测(closePrincipal 特判两边均=平掉额300,但消费路径不同): + /// 盘中 = 0.036164835616 —— CalcDailyCompoundInterest 以 closePosi(300) 全程重放 [4/27,4/30]; + /// EOD = 0.059041913305 —— InitSwapDealInterest closePercent==1 分支: + /// preEod.InterestIncomeSum(0.05 全腿待实现) + amountAtEnd(0.036165) - amountAtPrevEod(0.027123)。 + /// + /// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项; + /// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。 + /// + [TestMethod] + public void 复利_mode2_部分平仓_双入口口径钉住现状() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true); + var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var intraday = CreateService().GetIntradayUnwindInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, PreClose, 0m, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null); + + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, intraday.Count); + Assert.AreEqual(1, eodPostClose.Count); + Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}"); + + // 钉住两入口各自的当前值(容差 1e-9 级,防任何实现漂移) + Assert.AreEqual(0.036164835616m, intraday[0].InterestAmount, 0.000000001m, + "盘中口径:closePosi(平掉额300) 全程重放利息。此值变化=盘中复利口径漂移"); + Assert.AreEqual(0.059041913305m, eodPostClose[0].InterestAmount, 0.000000001m, + "EOD口径:preEod待实现(0.05) + 平掉额末段增量(0.009042)。此值变化=EOD平仓后收盘复利口径漂移"); + } + + /// + /// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。 + /// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等, + /// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。 + /// + [TestMethod] + public void 单利_mode2_部分平仓_双入口口径留档() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var intraday = CreateService().GetIntradayUnwindInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, PreClose, 0m, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null); + + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, intraday.Count); + Assert.AreEqual(1, eodPostClose.Count); + Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}"); + Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}"); + // 钉住"两入口非零"这一最低限度事实;数值差异本身是记录项,不是失败项 + Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0"); + Assert.IsTrue(eodPostClose[0].InterestAmount != 0m, "EOD单利结息额不应为0"); + } + + /// + /// mode9 全平(EOD,posi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额), + /// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。 + /// + [TestMethod] + public void 复利_mode9_全平_兜底覆盖生效结息额非零() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.标的期初全价, InterestTypeEnum.复利, floating: true); + var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + // 全平:剩余=0,平掉=全部 1000 + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, 0m, 0m, 0m, PreClose, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count); + Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}"); + Assert.IsTrue(result[0].InterestAmount != 0m, + "mode9 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)"); + } + } +} From 018d7e777fc1b9fe4ab734672588193859006a03 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 15:57:52 +0800 Subject: [PATCH 19/43] =?UTF-8?q?refactor(swap):=20InterestCalcRequest=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=AF=B9=E8=B1=A1=E2=80=94=E2=80=94GetIntere?= =?UTF-8?q?sts=20=E5=8F=8C=E6=98=BE=E5=BC=8F=E5=85=A5=E5=8F=A3=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BA=E5=8D=95=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 InterestCalcRequest(SwapModule 根): - 承载 GetInterests 全部有效入参(needPrice/grossPrice 死参数不承载),私有构造; - 仅两个场景工厂可构造:IntradayUnwind(平仓前剩余/实际平掉额/B语义比例)、 EodPostCloseSettle(平仓后剩余/实际平掉额/恒1全额结息)——工厂形参名即场景语义, 物理上防止两套名义本金语义混传(6fdc7d80 错账的温床); - GetIntradayUnwindInterests / CalcEodPostCloseSettleInterests 签名收敛为单参数 req, 生产调用点(GetUnwindInterests / SaveAutoEodWithCloseInterestPosition)改工厂构造; - 原 20 参 GetInterests / 19 参 CalcSwapInterests 保留为底层实现与测试兼容层(十余处测试直调,不动)。 验证:定向 140 测试通过;全量 902(+3 字符化测试)= 145失败/745通过/12跳过, 与基线逐位一致,零回归。 --- .../GetInterestsEntrySemanticsTest.cs | 14 +-- .../Modules/SwapModule/InterestCalcRequest.cs | 91 +++++++++++++++++++ .../Modules/SwapModule/SwapDealService.cs | 43 ++------- .../SwapModule/SwapEodPositionService.cs | 37 +++----- 4 files changed, 121 insertions(+), 64 deletions(-) create mode 100644 YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs index 7cf471e0..bcabc802 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -140,9 +140,10 @@ namespace YLErp.Modules.SwapModule var eodPositions = new List { preEod }; var positions = new List { position }; - var intraday = CreateService().GetIntradayUnwindInterests(td, td.trade_extend, UnwindDate, UnwindDate, - eodPositions, positions, PreClose, PreClose, 0m, Closed, ClosePercent, - (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null); + var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( + td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, + PreClose, PreClose, 0m, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, @@ -174,9 +175,10 @@ namespace YLErp.Modules.SwapModule var eodPositions = new List { preEod }; var positions = new List { position }; - var intraday = CreateService().GetIntradayUnwindInterests(td, td.trade_extend, UnwindDate, UnwindDate, - eodPositions, positions, PreClose, PreClose, 0m, Closed, ClosePercent, - (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null); + var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( + td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, + PreClose, PreClose, 0m, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, diff --git a/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs new file mode 100644 index 00000000..1be9364d --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs @@ -0,0 +1,91 @@ +namespace YLErp.Modules.SwapModule; + +/// +/// GetInterests 参数对象(2026-08 参数显式化)。 +/// +/// 动机:原 GetInterests 20 个位置参数中,名义本金簇(posiNotionalValue/closePosiNotionalValue/closePercent) +/// 在【盘中平仓】与【EOD 平仓后收盘】两类场景下语义相反(详见 GetInterests "根因位置"注释与 +/// GetInterestsEntrySemanticsTest 的口径留档),位置参数无法表达该约束。 +/// +/// 用法:只能经两个场景工厂构造——工厂形参名即该场景语义(平仓前剩余 / 平仓后剩余 / 实际平掉额), +/// 物理上防止两套语义混传。needPrice/grossPrice 为原方法死参数(体内零消费),本对象不承载。 +/// +public sealed class InterestCalcRequest +{ + public trade Td { get; } + public trade_extend TradeExtend { get; } + public DateTime ValueDate { get; } + public DateTime UnwindDate { get; } + public List EodPositions { get; } + public List Positions { get; } + + /// 当日适用名义本金。语义随场景:盘中=平仓【前】剩余;EOD平仓后收盘=平仓【后】剩余;EOD增量=当前剩余。 + public decimal PosiNotionalValue { get; } + public decimal PosiLongNotionalValue { get; } + public decimal PosiShortNotionalValue { get; } + + /// 本次实际平掉本金(两场景恒同义)。mode2 无条件覆盖 / mode9 全平兜底的输入。 + public decimal ClosePosiNotionalValue { get; } + + /// 平仓比例。语义随场景:盘中=实际比例(B 占剩余);EOD平仓后收盘=恒1(全额结息)。 + public decimal ClosePercent { get; } + + public int EventType { get; } + public bool TdClose { get; } + public decimal OrginPv { get; } + public bool Add { get; } + public bool NewCalcLast { get; } + public List CloseList { get; } + + private InterestCalcRequest( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal closePosiNotionalValue, decimal closePercent, + int eventType, bool tdClose, decimal orginPv, + bool add, bool newCalcLast, List closeList) + { + Td = td; TradeExtend = tradeExtend; ValueDate = valueDate; UnwindDate = unwindDate; + EodPositions = eodPositions; Positions = positions; + PosiNotionalValue = posiNotionalValue; PosiLongNotionalValue = posiLongNotionalValue; + PosiShortNotionalValue = posiShortNotionalValue; ClosePosiNotionalValue = closePosiNotionalValue; + ClosePercent = closePercent; EventType = eventType; TdClose = tdClose; OrginPv = orginPv; + Add = add; NewCalcLast = newCalcLast; CloseList = closeList; + } + + /// + /// 【盘中平仓/互换结息】场景(→ GetIntradayUnwindInterests,settment:false 盘中重放)。 + /// + /// 平仓【前】实时剩余本金(原 GetUnwindInterests.stockEqvNotional)。 + /// 本次实际平掉本金(= preCloseNotional × closePercentRemaining)。 + /// 平仓比例,B 语义【占剩余】(前端传 A 占期初须先经 ToRemainingClosePercent 转换)。 + public static InterestCalcRequest IntradayUnwind( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal preCloseNotional, decimal preCloseLongNotional, decimal preCloseShortNotional, + decimal closedNotional, decimal closePercentRemaining, + int eventType, bool tdClose, decimal orginPv, + bool add, bool newCalcLast, List closeList) + => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + preCloseNotional, preCloseLongNotional, preCloseShortNotional, + closedNotional, closePercentRemaining, + eventType, tdClose, orginPv, add, newCalcLast, closeList); + + /// + /// 【EOD 当日有平仓后的收盘结息】场景(→ CalcEodPostCloseSettleInterests,settment:false 全额结息)。 + /// 该场景触发 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底(见其"根因位置"注释,勿删)。 + /// + /// 平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位)。 + /// 本次实际平掉本金。 + public static InterestCalcRequest EodPostCloseSettle( + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal remainingNotionalAfterClose, decimal remainingLongNotional, decimal remainingShortNotional, + decimal closedNotional, + int eventType, bool tdClose, decimal orginPv, + bool add, bool newCalcLast) + => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + remainingNotionalAfterClose, remainingLongNotional, remainingShortNotional, + closedNotional, 1m, // 恒1:本次事件全额结息(非 closeNational / 期初比例) + eventType, tdClose, orginPv, add, newCalcLast, closeList: null); +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index b22c70e7..00da0eae 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -486,10 +486,11 @@ namespace YLErp.Modules.SwapModule && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; - // 显式入口(语义见 GetIntradayUnwindInterests 注释):平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放 - interests = GetIntradayUnwindInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, + // 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind) + interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( + td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, - closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList); + closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList)); return interests; } @@ -621,37 +622,13 @@ namespace YLErp.Modules.SwapModule /// /// /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。 - /// - /// 语义契约(与 EOD 平仓后收盘的 CalcEodPostCloseSettleInterests 相反,勿混用): - /// preCloseNotional = 平仓【前】实时剩余本金(原 GetUnwindInterests 的 stockEqvNotional); - /// closedNotional = 本次实际平掉本金(= preCloseNotional × closePercentRemaining); - /// closePercentRemaining = 平仓比例,B 语义【占剩余】(前端传 A 占期初,须先经 ToRemainingClosePercent 转换); - /// 计息走 CalcUnwindInterest 全区间重放(orginPv 参与保证金腿差分)。 - /// - /// needPrice/grossPrice 为 GetInterests 的历史死参数(方法体内无消费),本入口不再暴露。 + /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。 /// - public List GetIntradayUnwindInterests( - trade td, - trade_extend tradeExtend, - DateTime valueDate, - DateTime unwindDate, - List eodPositions, - List positions, - decimal preCloseNotional, - decimal preCloseLongNotional, - decimal preCloseShortNotional, - decimal closedNotional, - decimal closePercentRemaining, - int eventType, - bool tdClose, - decimal orginPv, - bool add, - bool newCalcLast, - List closeList) - => GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, - preCloseNotional, preCloseLongNotional, preCloseShortNotional, closedNotional, - closePercentRemaining, eventType, tdClose, needPrice: false, grossPrice: 0m, - orginPv, add, settment: false, newCalcLast, closeList); + public List GetIntradayUnwindInterests(InterestCalcRequest req) + => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, + req.PosiNotionalValue, req.PosiLongNotionalValue, req.PosiShortNotionalValue, req.ClosePosiNotionalValue, + req.ClosePercent, req.EventType, req.TdClose, needPrice: false, grossPrice: 0m, + req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); public List GetInterests( trade td, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 795b5829..c7c81be6 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -105,29 +105,15 @@ namespace YLErp.Modules.SwapModule /// /// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调 /// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。 - /// - /// 语义契约(与盘中 SwapDealService.GetIntradayUnwindInterests 相反,勿混用): - /// remainingNotionalAfterClose = 平仓【后】剩余本金(GetInterests.posiNotionalValue 形参位); - /// closedNotional = 本次实际平掉本金(GetInterests.closePosiNotionalValue 形参位); - /// 结息比例恒 1(本次事件全额结息)。该组合会触发 GetInterests 内 mode2 无条件覆盖 / - /// mode9 全平兜底(见其"根因位置"注释,勿删)。 - /// 计息走 CalcUnwindInterest 全区间重放(settment:false)。 - /// + /// 语义契约见 InterestCalcRequest.EodPostCloseSettle 工厂注释(平仓后剩余 + 实际平掉额 + 恒1全额结息, + /// 触发 GetInterests 内 mode2/mode9 本金修正)。计息走 CalcUnwindInterest 全区间重放。 /// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。 /// - protected virtual List CalcEodPostCloseSettleInterests( - trade td, trade_extend tradeExtend, - DateTime valueDate, DateTime unwindDate, - List eodPositions, List positions, - decimal remainingNotionalAfterClose, decimal remainingLongNotional, decimal remainingShortNotional, - decimal closedNotional, - int eventType, bool tdClose, - decimal grossPrice, decimal orginPv, - bool add, bool newCalcLast) - => CalcSwapInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, - remainingNotionalAfterClose, remainingLongNotional, remainingShortNotional, - closedNotional, 1m, eventType, tdClose, needPrice: true, grossPrice, orginPv, - add, settment: false, newCalcLast, closeList: null); + protected virtual List CalcEodPostCloseSettleInterests(InterestCalcRequest req) + => CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, + req.PosiNotionalValue, req.PosiLongNotionalValue, req.PosiShortNotionalValue, + req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, needPrice: true, + grossPrice: 0m, req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) @@ -1333,11 +1319,12 @@ namespace YLErp.Modules.SwapModule List preEodPositions = new List(); preEodPositions.Add(eodPayPosition); var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true; - // 显式入口(语义见 CalcEodPostCloseSettleInterests 注释):平仓后剩余本金 + 实际平掉额 + 恒1全额结息。 - // 该组合会触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 - var interests = CalcEodPostCloseSettleInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, + // 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle)。 + // 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 + var interests = CalcEodPostCloseSettleInterests(InterestCalcRequest.EodPostCloseSettle( + td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, - eventType, tdClose: false, grossPrice, orginPv, add: true, newCalcLast: autoSwap || calcLast); + eventType, tdClose: false, orginPv, add: true, newCalcLast: autoSwap || calcLast)); // TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。 // interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。 // manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。 From 4a3fee92928493e8fb9f756ee6dfa87fa278ec96 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 16:57:56 +0800 Subject: [PATCH 20/43] =?UTF-8?q?refactor(swap)+test:=20=E5=88=A0=20GetInt?= =?UTF-8?q?erests/CalcSwapInterests=20=E6=AD=BB=E5=8F=82=E6=95=B0=20needPr?= =?UTF-8?q?ice/grossPrice=EF=BC=9B=E8=A1=A5=E5=B7=A5=E5=8E=82=E2=86=92?= =?UTF-8?q?=E6=8E=A5=E7=BC=9D=E6=98=A0=E5=B0=84=E9=92=89=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 死参数收口(另一半): - SwapDealService.GetInterests 删 needPrice/grossPrice(体内零消费,2026-08 验证); InitSwapDealInterest.needPrice 同为死参数一并删 - SwapEodPositionService.CalcSwapInterests 签名+转发同步;两个 EOD 生产调用点 (SaveAutoEodInterestPosition/SaveEodInterestPositionCopy) 重排实参; CalcEodPostCloseSettleInterests/GetIntradayUnwindInterests 委托同步 - 14 个测试文件 ~44 处直调点机械更新(8 处 override 签名 + 36 处调用实参) - 注意:EOD 编排链(DealInterests→Save*家族)的 grossPrice(期初不含费价)有真实用途,保留未动 新增钉子:CalcEodPostCloseSettleInterests 工厂→接缝参数映射测试—— CalcSwapInterestsCapture 捕获 stub 断言 EodPostCloseSettle 的完整转发契约 (posi=平仓后剩余/closePosi=平掉额/恒1/settment:false/orginPv 等 11 项)。 该段位置转发含三个相邻同型 decimal,编译器不查错位,此测试兜底。 验证:定向 241 测试通过(含 T0/T1 Excel 验证期望值、EntrySemantics 精确值钉子—— 任何 decimal 错位即红);全量 903=145失败/746通过/12跳过,与基线逐位一致。 --- .../SwapModule/BondTrsAutoSwapScenarioTest.cs | 4 +- .../ConsumedInterestScenarioTest.cs | 8 +- .../SwapModule/DealInterestsScenarioTest.cs | 34 +++---- .../GLMS20260703CloseInterestTest.cs | 2 +- .../GetInterestsEntrySemanticsTest.cs | 91 ++++++++++++++++++- .../SwapModule/GetInterestsUnitTest_T0.cs | 8 +- .../SwapModule/GetInterestsUnitTest_T1.cs | 14 +-- .../Margin/MarginInterestGoldenReplayTest.cs | 4 +- .../SwapModule/MultiStepConservationTest.cs | 12 +-- .../PrepaidPrincipalCloseTraceTest.cs | 2 +- .../PrepaidPrincipalClosingChainTraceTest.cs | 4 +- ...wapCloseConversationCasesRegressionTest.cs | 2 +- .../SwapInterestScenario1And2Test.cs | 10 +- .../SwapInterestScenario3And4FloatingTest.cs | 10 +- .../SwapPositionComposeScenarioTest.cs | 8 +- .../SwapSingleTradeVerificationTest.cs | 10 +- .../SwapUnwindPrepayPrincipalBugTdd.cs | 10 +- .../SwapUnwindSameDayDoublePartialTest.cs | 2 +- .../Modules/SwapModule/SwapDealService.cs | 7 +- .../SwapModule/SwapEodPositionService.cs | 19 ++-- 20 files changed, 172 insertions(+), 89 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index 85052b9c..a53dcffa 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -146,8 +146,8 @@ namespace YLErp.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { return positions.Select(p => new swap_flow_event diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs index 866b4f3a..c7bfc2f7 100644 --- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs @@ -126,7 +126,7 @@ namespace YLErp.Modules.SwapModule var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, Principal, Principal, closePercent, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); return interests[0]; @@ -357,7 +357,7 @@ namespace YLErp.Modules.SwapModule var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); @@ -421,7 +421,7 @@ namespace YLErp.Modules.SwapModule var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate, new List { preEod }, new List { position }, remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m, - (int)SwapEventTypeEnum.平仓, true, false, 0m, remainingPrincipal, + (int)SwapEventTypeEnum.平仓, true, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal; @@ -467,7 +467,7 @@ namespace YLErp.Modules.SwapModule var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List { preEod }, new List { position }, remainingPrincipal, remainingPrincipal, 0m, remainingPrincipal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingPrincipal, + (int)SwapEventTypeEnum.平仓, false, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); AssertDecimal(pendingInterest, result.InterestAmount, diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 07f9b289..c0b83382 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -65,8 +65,8 @@ namespace YLErp.Modules.SwapModule List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, - int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, + int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { @@ -78,8 +78,8 @@ namespace YLErp.Modules.SwapModule return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } // public 包装:让测试能调用 protected 方法 @@ -1230,7 +1230,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, closeDate, closeDate, new List { previousEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, orginPv, + (int)SwapEventTypeEnum.平仓, false, orginPv, false, settment: false, newCalcLast: false, closeList: null).Single(); AssertDecimal(remainingNotional, result.InterestPrincipal, @@ -1269,7 +1269,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, firstCloseDate, firstCloseDate, new List(), new List { position }, originalNotional, originalNotional, 0m, remainingNotional, 0.5m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -1287,13 +1287,13 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, firstCloseDate, firstCloseDate, new List(), new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var replayAtFinalClose = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List(), new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var expectedFinalInterest = firstCloseEod.InterestIncomeSum + replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount; @@ -1307,7 +1307,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, finalCloseDate, finalCloseDate, new List { firstCloseEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -1435,7 +1435,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, partialCloseDate, partialCloseDate, new List { previousEod }, new List { position }, notional, notional, 0m, partialNotional, partialPercent, - (int)SwapEventTypeEnum.平仓, false, false, 0m, notional, + (int)SwapEventTypeEnum.平仓, false, notional, settment: false).Single(); AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), @@ -1445,7 +1445,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, maturityDate, maturityDate, new List(), new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false, newCalcLast: true).Single(); AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), @@ -1576,7 +1576,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: true).Single(); Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m, $"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}"); @@ -1707,7 +1707,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: true).Single(); Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m, $"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}"); @@ -1740,7 +1740,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, finalCloseDate, finalCloseDate, new List { intermediateEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: false).Single(); AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount, "0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额"); @@ -1830,7 +1830,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, finalCloseDate, finalCloseDate, new List { previousEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false).Single(); AssertDecimal(expectedInterest, result.InterestAmount, @@ -1928,7 +1928,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, partialCloseDate, partialCloseDate, new List { preCloseEod }, new List { position }, originalNotional, originalNotional, 0m, partialNotional, partialClosePercent, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount, $"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列"); @@ -1977,7 +1977,7 @@ namespace YLErp.Modules.SwapModule td, td.trade_extend, finalCloseDate, finalCloseDate, new List { finalPreEod }, new List { position }, remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, remainingNotional, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount, $"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列"); diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs index bb29f270..ccd5c26d 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs @@ -212,7 +212,7 @@ namespace YLErp.Modules.SwapModule Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional 1m, // closePercent (int)SwapEventTypeEnum.平仓, - false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv + false, Notional, // tdClose / orginPv false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count); return interests[0]; diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs index bcabc802..46f8f1e4 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -147,7 +147,7 @@ namespace YLErp.Modules.SwapModule var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, - (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: true, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, intraday.Count); @@ -182,7 +182,7 @@ namespace YLErp.Modules.SwapModule var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m, - (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: true, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, intraday.Count); @@ -210,7 +210,7 @@ namespace YLErp.Modules.SwapModule // 全平:剩余=0,平掉=全部 1000 var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, 0m, 0m, 0m, PreClose, 1m, - (int)SwapEventTypeEnum.平仓, tdClose: false, needPrice: true, grossPrice: 1m, orginPv: PreClose, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: true, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, result.Count); @@ -218,5 +218,90 @@ namespace YLErp.Modules.SwapModule Assert.IsTrue(result[0].InterestAmount != 0m, "mode9 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)"); } + + #region CalcEodPostCloseSettleInterests 接缝映射钉子 + + /// + /// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。 + /// + private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService + { + public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { } + + public List CapturedCloseList = null; + public bool CapturedTdClose; + public int CapturedEventType; + public decimal CapturedPosiNotional; + public decimal CapturedClosePosiNotional; + public decimal CapturedClosePercent; + public decimal CapturedOrginPv; + public bool CapturedAdd; + public bool CapturedSettment; + public bool CapturedNewCalcLast; + public int CallCount; + + protected override List CalcSwapInterests( + trade td, trade_extend tradeExtend, + DateTime valueDate, DateTime unwindDate, + List eodPositions, List positions, + decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal closePosiNotionalValue, decimal closePrecent, + int eventType, bool tdClose, + decimal orginPv, + bool add = false, bool settment = true, bool newCalcLast = false, + List closeList = null) + { + CallCount++; + CapturedTdClose = tdClose; CapturedEventType = eventType; + CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue; + CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv; + CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast; + CapturedCloseList = closeList; + return new List(); + } + + public List ExposedEodPostCloseSettle(InterestCalcRequest req) + => CalcEodPostCloseSettleInterests(req); + } + + /// + /// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests → + /// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节 + /// (posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位), + /// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。 + /// + [TestMethod] + public void EOD平仓后收盘_工厂到接缝_参数映射钉死() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); + var positions = new List { position }; + + var stub = new CalcSwapInterestsCapture(); + var req = InterestCalcRequest.EodPostCloseSettle( + td, td.trade_extend, UnwindDate, UnwindDate, + new List { preEod }, positions, + remainingNotionalAfterClose: Remaining, remainingLongNotional: Remaining, remainingShortNotional: 0m, + closedNotional: Closed, + eventType: (int)SwapEventTypeEnum.平仓, tdClose: false, + orginPv: PreClose, add: true, newCalcLast: false); + + stub.ExposedEodPostCloseSettle(req); + + Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)"); + Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红"); + Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)"); + Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, stub.CapturedEventType); + Assert.IsFalse(stub.CapturedTdClose); + Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处"); + Assert.IsTrue(stub.CapturedAdd); + Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)"); + Assert.IsFalse(stub.CapturedNewCalcLast); + Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList"); + } + + #endregion } } diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs index cb664f25..c4aad82c 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs @@ -230,7 +230,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; } @@ -246,7 +246,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, Principal, Principal, Principal, Principal, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList); + false, Principal, false, settment: true, newCalcLast: false, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; } @@ -265,7 +265,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; } @@ -281,7 +281,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, Principal, Principal, Principal, Principal, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList); + false, Principal, false, settment: true, newCalcLast: false, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; } diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs index 4d074d8d..672e80bb 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs @@ -324,7 +324,7 @@ namespace YLErp.Modules.SwapModule new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -348,7 +348,7 @@ namespace YLErp.Modules.SwapModule new List { position }, Principal, Principal, Principal, Principal, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList); + false, Principal, false, settment: true, newCalcLast: false, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -373,7 +373,7 @@ namespace YLErp.Modules.SwapModule new List { position }, Principal, Principal, Principal, Principal, closePercent, (int)SwapEventTypeEnum.自动互换, - false, false, 0, Principal, false, settment: false, newCalcLast: false, closeList: closeList); + false, Principal, false, settment: false, newCalcLast: false, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -409,7 +409,7 @@ namespace YLErp.Modules.SwapModule new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -432,7 +432,7 @@ namespace YLErp.Modules.SwapModule new List { position }, Principal, Principal, Principal, Principal, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0, Principal, false, settment: true, newCalcLast: false, closeList: closeList); + false, Principal, false, settment: true, newCalcLast: false, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -1718,7 +1718,7 @@ namespace YLErp.Modules.SwapModule new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; @@ -1749,7 +1749,7 @@ namespace YLErp.Modules.SwapModule new List { position }, posiNotional, posiNotional, posiNotional, posiNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); + false, posiNotional, false, settment: false, newCalcLast: newCalcLast, closeList: closeList); AssertInterestEqual(1, interests.Count); return interests[0]; diff --git a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs index bb9a65fa..62c948dc 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs @@ -100,8 +100,8 @@ namespace UnitTestProject.Modules.SwapModule.Margin oldList = svc.GetInterests(td, extend, valueDate, valueDate, preEods, marginPositions, 0m, 0m, 0m, 0m, 1.0m, - (int)SwapEventTypeEnum.自动互换, tdClose: false, needPrice: false, - grossPrice: 0m, orginPv: 0m, + (int)SwapEventTypeEnum.自动互换, tdClose: false, + orginPv: 0m, add: false, settment: true, newCalcLast: false, closeList: null); } catch (Exception ex) diff --git a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs index 1dfb1d64..12196bdd 100644 --- a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs +++ b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs @@ -120,7 +120,7 @@ namespace YLErp.Modules.SwapModule var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); return interests.Count > 0 ? interests[0].InterestAmount : 0m; } @@ -143,7 +143,7 @@ namespace YLErp.Modules.SwapModule var interests = service.GetInterests(td, td.trade_extend, valueDate, valueDate, new List { preEod }, new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: true, newCalcLast: false); if (interests.Count == 0) return (0m, 0m); return (interests[0].TdInterestAmount, interests[0].InterestAmount); @@ -314,7 +314,7 @@ namespace YLErp.Modules.SwapModule var i5 = svc5.GetInterests(td, td.trade_extend, day5, day5, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal swap1 = i5.Count > 0 ? i5[0].InterestAmount : 0m; @@ -323,7 +323,7 @@ namespace YLErp.Modules.SwapModule var i10 = svc10.GetInterests(td, td.trade_extend, day10, day10, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal swap2 = i10.Count > 0 ? i10[0].InterestAmount : 0m; @@ -333,7 +333,7 @@ namespace YLErp.Modules.SwapModule var i15 = svc15.GetInterests(td, td.trade_extend, day15, day15, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal finalUnwind = i15.Count > 0 ? i15[0].InterestAmount : 0m; @@ -363,7 +363,7 @@ namespace YLErp.Modules.SwapModule var interests = svc.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, Principal, Principal, 1m, - (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); return interests.Count > 0 ? interests[0].InterestAmount : 0m; } diff --git a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs index 45e60bef..fdfeb04c 100644 --- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs +++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs @@ -104,7 +104,7 @@ namespace YLErp.Modules.SwapModule var eod = new List { MakeEod(valueDate, PrepayRemaining, 0m) }; var fe = _svc.GetInterests(td, td.trade_extend, FullDate, FullDate, eod, new List { pos }, PrepayFix, PrepayFix, PrepayFix, PrepayFix, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0, PrepayFix, false, + (int)SwapEventTypeEnum.平仓, false, PrepayFix, false, settment: false, newCalcLast: calcLast, closeList: null)[0]; var trace = SwapCalcTrace.Dump(); Console.WriteLine(trace); diff --git a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs index 39746577..44e9f844 100644 --- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs +++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs @@ -102,8 +102,8 @@ namespace YLErp.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { return positions.Select(p => new swap_flow_event diff --git a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs index f10e3439..e4abce0b 100644 --- a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs @@ -82,7 +82,7 @@ namespace YLErp.Modules.SwapModule new List { previousEod }, new List { position }, closeCase.RemainingNotional, closeCase.RemainingNotional, 0m, closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0m, + false, closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional, add: false, settment: false, newCalcLast: false).Single(); diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs index bf3f2b0a..12b3941d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs @@ -57,16 +57,16 @@ namespace UnitTestProject.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { var svc = new StubSwapDealService( new OptUserInfo(0, nameof(SwapInterestScenario1And2Test), OptUserFrom.UnitTest), _floatRates); return svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate, @@ -213,7 +213,7 @@ namespace UnitTestProject.Modules.SwapModule prevEod, new List { position }, closeNotional, closeNotional, 0m, closeNotional, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity); + false, closeNotional, false, settment: false, newCalcLast: isMaturity); Assert.AreEqual(1, interests.Count); return interests[0]; } diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs index 4d396b0a..a86c20e4 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -179,16 +179,16 @@ namespace UnitTestProject.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { var svc = new RealSwapDealService( new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents); var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); // 捕获 base InterestPrincipal(= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。 LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m; return interests; @@ -396,7 +396,7 @@ namespace UnitTestProject.Modules.SwapModule prevEod, new List { position }, closeNotional, closeNotional, 0m, closeNotional, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity); + false, closeNotional, false, settment: false, newCalcLast: isMaturity); Assert.AreEqual(1, interests.Count); return interests[0]; } diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 3749384a..166bd4dc 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -80,15 +80,15 @@ namespace YLErp.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { LastInterestCalculationPositions = positions; return base.CalcSwapInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) diff --git a/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs b/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs index bdcfd67e..f4ddee13 100644 --- a/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs @@ -60,16 +60,16 @@ namespace UnitTestProject.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, - decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { var svc = new StubSwapDealService( new OptUserInfo(0, nameof(SwapSingleTradeVerificationTest), OptUserFrom.UnitTest), _floatRates); return svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate, @@ -215,7 +215,7 @@ namespace UnitTestProject.Modules.SwapModule prevEod, new List { position }, closeNotional, closeNotional, 0m, closeNotional, 1m, (int)SwapEventTypeEnum.平仓, - false, false, 0m, closeNotional, false, settment: false, newCalcLast: isMaturity); + false, closeNotional, false, settment: false, newCalcLast: isMaturity); Assert.AreEqual(1, interests.Count); return interests[0]; } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs index ee29d70d..d29a3e80 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -95,7 +95,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null); + false, UnderlyingNotional, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); return interests[0]; } @@ -113,7 +113,7 @@ namespace YLErp.Modules.SwapModule eodPositions, new List { position }, notional, notional, notional, notional, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null); + false, notional, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); return interests[0]; } @@ -281,7 +281,7 @@ namespace YLErp.Modules.SwapModule eod, new List { position }, fix, fix, fix, fix, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, fix, false, settment: false, newCalcLast: false, closeList: null); + false, fix, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "预付金腿应生成 1 条 flow_event"); return interests[0]; } @@ -375,7 +375,7 @@ namespace YLErp.Modules.SwapModule eod, new List { position }, notional, notional, notional, notional * closePercent, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, notional, false, settment: false, newCalcLast: false, closeList: null); + false, notional, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "非预付金腿应生成 1 条 flow_event"); return interests[0]; } @@ -490,7 +490,7 @@ namespace YLErp.Modules.SwapModule eodPos, new List { position }, baseP, baseP, baseP, baseP * closePercent, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, baseP, false, settment: eodPath, newCalcLast: false, closeList: null); + false, baseP, false, settment: eodPath, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, $"mode={mode} 应生成 1 条 flow_event"); return interests[0]; } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs index 5e18c5d1..27a957f8 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs @@ -123,7 +123,7 @@ namespace YLErp.Modules.SwapModule MakeLastEod(), new List { position }, currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent, (int)SwapEventTypeEnum.平仓, - false, false, 0, N, false, settment: false, newCalcLast: false, closeList: null); + false, N, false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event"); return interests[0]; } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 00da0eae..e05f3b3d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -627,7 +627,7 @@ namespace YLErp.Modules.SwapModule public List GetIntradayUnwindInterests(InterestCalcRequest req) => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, req.PosiNotionalValue, req.PosiLongNotionalValue, req.PosiShortNotionalValue, req.ClosePosiNotionalValue, - req.ClosePercent, req.EventType, req.TdClose, needPrice: false, grossPrice: 0m, + req.ClosePercent, req.EventType, req.TdClose, req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); public List GetInterests( @@ -644,8 +644,6 @@ namespace YLErp.Modules.SwapModule decimal closePrecent, int eventType, bool tdClose, - bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, @@ -1110,7 +1108,7 @@ namespace YLErp.Modules.SwapModule } return InitSwapDealInterest(td, valueDate, endDate, rate, position, add, swap, posiPrincipal, - closePrincipal, closePercent, annualDays, eventType, preEod, false, + closePrincipal, closePercent, annualDays, eventType, preEod, orginPv, calcFirst, calcLast, consumedInterest); } /// @@ -1165,7 +1163,6 @@ namespace YLErp.Modules.SwapModule int annualDays, int eventType, eod_swap_position preEodPosition, - bool needPrice, decimal orginPv, bool calcFirst, bool calcLast, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index c7c81be6..0af04ee6 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -82,8 +82,9 @@ namespace YLErp.Modules.SwapModule } /// - /// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算) + /// 计算利息腿利息明细(生产: new SwapDealService(this).GetInterests;测试: 用StubSwapDealService内存算)。 /// 参数与 SwapDealService.GetInterests 完全一致,保证行为不变。 + /// (needPrice/grossPrice 死参数已随 2026-08 收口删除,两侧同步。) /// protected virtual List CalcSwapInterests( trade td, trade_extend tradeExtend, @@ -91,15 +92,15 @@ namespace YLErp.Modules.SwapModule List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, - int eventType, bool tdClose, bool needPrice, - decimal grossPrice, decimal orginPv, + int eventType, bool tdClose, + decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, - closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, - grossPrice, orginPv, add, settment, newCalcLast, closeList); + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } /// @@ -112,8 +113,8 @@ namespace YLErp.Modules.SwapModule protected virtual List CalcEodPostCloseSettleInterests(InterestCalcRequest req) => CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, req.PosiNotionalValue, req.PosiLongNotionalValue, req.PosiShortNotionalValue, - req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, needPrice: true, - grossPrice: 0m, req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); + req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, + req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) @@ -1158,7 +1159,7 @@ namespace YLErp.Modules.SwapModule { orginPv = eodPayPosition.InterestPrincipalFix; } - var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, true, grossPrice, orginPv, true); + var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, orginPv, true); decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount); @@ -1576,7 +1577,7 @@ namespace YLErp.Modules.SwapModule { preEodPositions.Add(eodPayPosition); } - var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNational, posiShortNational, posiNotionalValue, closePercent, 0, false, needPrice, grossPrice, orginPv); + var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNational, posiShortNational, posiNotionalValue, closePercent, 0, false, orginPv); UpdateDbOption(newEodPayPosition); newEodPayPosition.PosiStatus = 0; From fdf6357c4ffd610b5a971553bad6ee91c8857708 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 14 Aug 2026 17:06:08 +0800 Subject: [PATCH 21/43] =?UTF-8?q?fix(EQD-7049):=20=E6=9C=9F=E6=9D=83?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E9=A1=B5=E6=A0=87=E7=9A=84=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=85=A8=E9=87=8F=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vueUnderlying.lookup 不再遍历全量 ylotc.underlyings(债券十几万量级导致 29s 下载/页面卡死),改为调用 /frontdata/AjaxGetUnderlyingSelect 按关键词服务端搜索 - 默认预拉 20 条(后端 MaxShowLength 硬截断),输入时异步搜索并刷新缓存(乱序响应由 token 丢弃) - 移除 BasicDataJs 的 标的Live 参数消除全量标的下载;IsCombined 由 IsSynthetic||IsBasket 映射,保留期权黑名单护栏(BlackLimit=1) - 同步修改东莞定制版 Structure_DZ --- YLErpWeb/Views/Pricing/Structure_DZ.cshtml | 2 +- YLErpWeb/Views/Pricing/structure.cshtml | 2 +- .../wwwroot/Scripts/app/pricing/structure.js | 74 ++++++++++++++----- .../Scripts/app/pricing/structure_dz.js | 74 ++++++++++++++----- 4 files changed, 116 insertions(+), 36 deletions(-) diff --git a/YLErpWeb/Views/Pricing/Structure_DZ.cshtml b/YLErpWeb/Views/Pricing/Structure_DZ.cshtml index 236f4f8c..18c7d47e 100644 --- a/YLErpWeb/Views/Pricing/Structure_DZ.cshtml +++ b/YLErpWeb/Views/Pricing/Structure_DZ.cshtml @@ -85,7 +85,7 @@ - + - +