diff --git a/.runsettings b/.runsettings new file mode 100644 index 00000000..e822241a --- /dev/null +++ b/.runsettings @@ -0,0 +1,15 @@ + + + + + + 0 + TestClass + + + 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/Framework/YLErp.Core/Interest/AccrualContext.cs b/Framework/YLErp.Core/Interest/AccrualContext.cs deleted file mode 100644 index 8e7dd77f..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(仅未接线的 MarginAccount.AccrueInterest 走此默认;生产融资腿/保证金腿均显式用 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/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/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/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/ContractReferenceCalc.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs new file mode 100644 index 00000000..abf233dd --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs @@ -0,0 +1,72 @@ +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现(确认书公式,TEST-MATRIX §8a)——全矩阵统一 oracle 供给。 + /// + /// 【独立性约束·勿破坏】本类只实现确认书公式原文,禁止引用任何生产计息引擎类 + /// (YLErp.Modules.SwapModule.Accrual.* / SwapDealService),否则 oracle 与被测对象同源, + /// 失去"独立参考"资格(oracle 分级第一级,见 TEST-MATRIX §7.4)。 + /// + /// 确认书公式(国联民生收益互换确认书-现券/ETF 四份一致): + /// 参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 + /// 结息额(平仓部分) = 实际平掉额 × 参考利率(绝对) + /// - k = 计息期包含的重置期个数;完整重置期 di = 重置频率(生产 7 天),末段不足按实际日历日 + /// - 重置期自计息期首日按重置频率依次推算;首个重置期始于计息期首日;末段收口到计息期最后一日 + /// - 利率确定日 = 每个重置期首日(重置日)的上一个营业日,取该日 FR007 + /// - 计息期 = 自起始日(含)至到期日(不含)——即算头不算尾 "10"(生产主力条款) + /// - 计息基准 A/365 + /// + /// 营业日准则:本参考实现按周末近似(周六/周日非营业日);法定节假日历由调用方通过 + /// 取价委托自行吸收(如按确定日提供同一利率)。测试与生产参数对齐(§8):重置 7 天 / 365。 + /// + public static class ContractReferenceCalc + { + /// + /// 参考利率(绝对) = ∏(1 + (FR007i+利差)×di/annualDays) − 1。 + /// + /// 计息期首日(含) + /// 计息期末日("10"不含/"11"含,由 calcLast 决定) + /// 重置频率天数(生产 7) + /// 利差(InterestRateDefault,如 +0.25% = 0.0025) + /// 取价委托:入参=利率确定日(重置日上一营业日),返回该日 FR007 + /// 算头(生产 "10"/"11" 为 true) + /// 算尾(生产 "10" 为 false) + /// 计息基准(生产 365) + public static decimal ReferenceRateAbsolute( + DateTime startDate, DateTime endDate, + int resetDays, decimal spread, + Func fixing, + bool calcFirst = true, bool calcLast = false, + int annualDays = 365) + { + var totalDays = (endDate - startDate).Days + (calcFirst ? 0 : -1) + (calcLast ? 1 : 0); + if (totalDays <= 0) return 0m; + + decimal factor = 1m; + var resetDate = startDate; // 首个重置期始于计息期首日 + var remaining = totalDays; + while (remaining > 0) + { + var di = Math.Min(resetDays, remaining); // 完整期 di=resetDays,末段按实际日历日 + var fixingDate = PreviousBusinessDay(resetDate); + var allIn = fixing(fixingDate) + spread; + factor *= 1m + allIn * di / annualDays; + remaining -= di; + resetDate = resetDate.AddDays(di); + } + return factor - 1m; + } + + /// 结息额(平仓部分)= 实际平掉额 × 参考利率(绝对)。 + public static decimal ClosedInterest(decimal closedNotional, decimal referenceRate) + => closedNotional * referenceRate; + + /// 利率确定日 = 重置日的上一营业日(周末近似)。 + public static DateTime PreviousBusinessDay(DateTime date) + { + do { date = date.AddDays(-1); } + while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + return date; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs new file mode 100644 index 00000000..7a3ce5ed --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceOracleTest.cs @@ -0,0 +1,196 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 契约参考实现 oracle 落地(TEST-MATRIX §7 第 5 步)——两段式: + /// + /// ① oracle 自验证:手算锚点直接钉 ContractReferenceCalc(独立于生产引擎,公式正确性 + /// 由裁决文档 §1.1/§1.2 已核过的手算值保证——真实规模 5000 万/2.05%/90 天 与玩具 4 天)。 + /// ② 引擎对照:主力族(mode9 标的期初全价 / mode2 合约名义本金规模 × FR007 × 复利 × "10") + /// 盘中 T+0 部分平仓 30%,GetInterests 重放结果 必须 == 契约 oracle(容差 0.01 元,§7.4)。 + /// 这是本矩阵第一个"契约公式独立参考实现"级 oracle 的引擎对照用例(此前仅有 Excel 手算/工单值)。 + /// + /// 引擎对照用恒定 FR007 利率表——刻意免疫"利率确定日=重置日上一营业日 vs 当日"的取价日 + /// 约定差异(任何确定日取到的都是同一利率),单独验证 ∏ 公式/重置期切分/算头不算尾/末段收口; + /// 取价日维度(E 维,66a97e03)由变利率用例在 oracle 侧钉住(§①第 4 例),引擎侧后续补。 + /// + /// 坐标登记:mode9/mode2 × 复利 × "10" × T+0 × 部分平仓30% × B=跨12个完整重置期+末段 × E=恒定利率。 + /// + [TestClass] + public class ContractReferenceOracleTest + { + // ── 生产参数(TEST-MATRIX §8:7 天重置 / A365 / 真实点差 +0.25% / 千万级名义)── + private const decimal Spread = 0.0025m; // 点差 +0.25%(确认书真实点差) + private const decimal Fr007 = 0.018m; // FR007 示意水平 1.8% → all-in 2.05% + private const int ResetDays = 7; + private const int AnnualDaysConst = 365; + private const decimal Notional = 50_000_000m; // 名义 5000 万 + private const decimal ClosedNotional = 15_000_000m; // 平掉 30% = 1500 万 + private const decimal ClosePercent = 0.3m; + + private static readonly DateTime StartDate = new(2026, 4, 27); // 周一,起息日 + private static readonly DateTime Unwind90 = new(2026, 7, 26); // 90 天 = 12×7 + 6 末段 + private static readonly DateTime Unwind89 = new(2026, 7, 25); // 89 天 = 12×7 + 5 末段 + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + + #region ① oracle 自验证(手算锚点) + + [TestMethod] + public void 契约公式_恒定利率_90天12整期加6天末段_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind90, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0205×7/365)^12 × (1+0.0205×6/365) − 1(python 高精度复核) + Assert.AreEqual(0.0050666026m, rate, 0.0000000009m, "90 天参考利率(绝对)必须等于 ∏ 公式手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75999.04m, interest, 0.01m, "平掉 1500 万 × 参考利率 = 裁决文档 §1.1 应结值"); + } + + [TestMethod] + public void 契约公式_恒定利率_89天末段5天_等于手算() + { + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, Unwind89, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + Assert.AreEqual(0.0050101727m, rate, 0.0000000009m, "89 天参考利率(绝对)手算值"); + + var interest = ContractReferenceCalc.ClosedInterest(ClosedNotional, rate); + Assert.AreEqual(75152.59m, interest, 0.01m); + } + + [TestMethod] + public void 契约公式_玩具参数_算头算尾4天_等于裁决文档手算锚点() + { + // 裁决文档 §1.2:300×[(1+0.011×3/365)×(1+0.011×1/365)−1] = 0.0361652(重置 3 天,利差 1%,FR 0.1%) + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 4, 27), new DateTime(2026, 4, 30), resetDays: 3, + spread: 0.01m, fixing: _ => 0.001m, + calcFirst: true, calcLast: true, annualDays: 365); + var interest = ContractReferenceCalc.ClosedInterest(300m, rate); + Assert.AreEqual(0.0361652m, interest, 0.000001m); + } + + [TestMethod] + public void 契约公式_分段变利率_利率确定日为重置日上一营业日() + { + // 计息期 [5/4(一), 5/15(五)) "10" → 11 天 = 7 + 4 末段;重置日 5/4、5/11(均为周一) + // 契约:利率确定日 = 重置日上一营业日 → 5/1(五)、5/8(五) + Assert.AreEqual(new DateTime(2026, 5, 1), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 4)), "5/4(一)的上一营业日是 5/1(五)"); + Assert.AreEqual(new DateTime(2026, 5, 8), ContractReferenceCalc.PreviousBusinessDay(new DateTime(2026, 5, 11)), "5/11(一)的上一营业日是 5/8(五)"); + + var fixings = new Dictionary + { + [new DateTime(2026, 5, 1)] = 0.02m, // 第一段 FR007 2.0% → all-in 2.25% + [new DateTime(2026, 5, 8)] = 0.03m, // 第二段 FR007 3.0% → all-in 3.25% + }; + var rate = ContractReferenceCalc.ReferenceRateAbsolute( + new DateTime(2026, 5, 4), new DateTime(2026, 5, 15), ResetDays, Spread, + d => fixings[d], calcFirst: true, calcLast: false, annualDays: AnnualDaysConst); + // 手算:(1+0.0225×7/365)×(1+0.0325×4/365)−1 = 0.0007878249 + Assert.AreEqual(0.0007878249m, rate, 0.0000000009m, + "分段变利率下每段必须用各自确定日的 FR007(E 维:取价日=重置日上一营业日)"); + } + + #endregion + + #region ② 引擎对照(恒定 FR007,免疫取价日约定) + + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService() : base( + new OptUserInfo(0, nameof(ContractReferenceOracleTest), OptUserFrom.UnitTest)) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; } + rate = (double)Fr007; + return true; + } + + /// fresh 重放无历史已结利息,覆写掉 DB 查询(本场景语义即 0)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; + } + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDaysConst, + InterestCalcMode = "10", // 算头不算尾(生产主力条款) + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-CONTRACT-REF-ORACLE", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position CreatePosition(InterestModeEnum mode) => + new() + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode, + InterestRateDefault = Spread, InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = ResetDays, interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject( + new List { new() { Date = ExerciseDate, Rate = Spread, Settlement = 0 } }) + }; + + /// 引擎盘中重放(T+0 fresh 持仓,T0 形状)vs 契约 oracle,容差 0.01 元。 + private static void AssertEngineMatchesOracle( + InterestModeEnum mode, DateTime unwindDate, decimal posi, decimal closePosi, + decimal expectedOracleInterest) + { + var td = CreateTrade(); + var position = CreatePosition(mode); + var interests = new StubSwapDealService().GetInterests( + td, td.trade_extend, unwindDate, unwindDate, + new List(), new List { position }, + posi, closePosi, ClosePercent, + (int)SwapEventTypeEnum.平仓, + tdClose: false, orginPv: posi, add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, interests.Count); + Assert.IsTrue(Math.Abs(interests[0].InterestAmount - expectedOracleInterest) <= 0.01m, + $"mode={mode} 引擎重放 {interests[0].InterestAmount} vs 契约 oracle {expectedOracleInterest}," + + $"diff={interests[0].InterestAmount - expectedOracleInterest}——引擎偏离确认书公式(TEST-MATRIX §8a)"); + } + + private static decimal OracleInterest(DateTime unwindDate) => + ContractReferenceCalc.ClosedInterest(ClosedNotional, + ContractReferenceCalc.ReferenceRateAbsolute( + StartDate, unwindDate, ResetDays, Spread, _ => Fr007, + calcFirst: true, calcLast: false, annualDays: AnnualDaysConst)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_90天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind90, Notional, Notional, OracleInterest(Unwind90)); + + [TestMethod] + public void 引擎_mode9_复利FR007_10_部分平仓30_89天_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.标的期初全价, Unwind89, Notional, Notional, OracleInterest(Unwind89)); + + [TestMethod] + public void 引擎_mode2_复利FR007_10_部分平仓30_显式平掉额_等于契约oracle() + => AssertEngineMatchesOracle(InterestModeEnum.合约名义本金规模, Unwind90, Notional, ClosedNotional, OracleInterest(Unwind90)); + + #endregion + } +} 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/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),多算方是手动路径而非自动路径"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index 85052b9c..e42c8de3 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -145,9 +145,9 @@ namespace YLErp.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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..457de583 100644 --- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs @@ -125,8 +125,8 @@ namespace YLErp.Modules.SwapModule var position = CreateCompoundPosition(); 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, + Principal, Principal, closePercent, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); return interests[0]; @@ -356,8 +356,8 @@ 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); @@ -420,8 +420,8 @@ 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, + remainingPrincipal, remainingPrincipal, 1m, + (int)SwapEventTypeEnum.平仓, true, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal; @@ -466,8 +466,8 @@ 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, + remainingPrincipal, remainingPrincipal, 1m, + (int)SwapEventTypeEnum.平仓, false, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); AssertDecimal(pendingInterest, result.InterestAmount, diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs index dafec3db..cc86d5be 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsGoldenReplayTest.cs @@ -47,7 +47,7 @@ namespace YLErp.Modules.SwapModule { DealInterests(interestList, eodPositions, new List(), settleDate, td, new List(), new List(), null, - posiLongNational, 0m, 0m, grossPrice, orginPv); + posiLongNational + 0m, 0m, grossPrice, orginPv); } } diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 07f9b289..079a6ba7 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -63,10 +63,10 @@ namespace YLErp.Modules.SwapModule trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, - decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal posiNotionalValue, 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) { @@ -77,9 +77,9 @@ 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); + eodPositions, positions, posiNotionalValue, + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } // public 包装:让测试能调用 protected 方法 @@ -99,7 +99,7 @@ namespace YLErp.Modules.SwapModule decimal orginPv = DealInterestsScenarioTest.Principal) { SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, - lastEodSwap, posiLongNotional, 0m, 1m, orginPv); + lastEodSwap, posiLongNotional + 0m, 1m, orginPv); return PersistedPositions.LastOrDefault(); } @@ -110,7 +110,7 @@ namespace YLErp.Modules.SwapModule decimal closeNotional, bool autoSwap) { SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval, - posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, + posiLongNotional + posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, DealInterestsScenarioTest.Principal); return PersistedPositions.LastOrDefault(); } @@ -121,7 +121,7 @@ namespace YLErp.Modules.SwapModule decimal grossPrice, decimal orginPv) { SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null, - false, posiLongNotional, posiShortNotional, grossPrice, orginPv); + false, posiLongNotional + posiShortNotional, grossPrice, orginPv); return PersistedPositions.LastOrDefault(); } @@ -134,7 +134,7 @@ namespace YLErp.Modules.SwapModule { DealInterests(interestList, eodPositions, new List(), settleDate, td, flowEvents, new List(), null, - posiLongNational, posiShortNational, closeNational, grossPrice, orginPv); + posiLongNational + posiShortNational, closeNational, grossPrice, orginPv); } } @@ -1229,8 +1229,8 @@ namespace YLErp.Modules.SwapModule var result = new SwapDealService(service).GetInterests( td, td.trade_extend, closeDate, closeDate, new List { previousEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, orginPv, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, orginPv, false, settment: false, newCalcLast: false, closeList: null).Single(); AssertDecimal(remainingNotional, result.InterestPrincipal, @@ -1268,8 +1268,8 @@ namespace YLErp.Modules.SwapModule var firstCloseInterest = dealService.GetInterests( td, td.trade_extend, firstCloseDate, firstCloseDate, new List(), new List { position }, - originalNotional, originalNotional, 0m, remainingNotional, 0.5m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + originalNotional, remainingNotional, 0.5m, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -1286,14 +1286,14 @@ namespace YLErp.Modules.SwapModule var replayAtPreviousEod = dealService.GetInterests( td, td.trade_extend, firstCloseDate, firstCloseDate, new List(), new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + remainingNotional, remainingNotional, 1m, + (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, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var expectedFinalInterest = firstCloseEod.InterestIncomeSum + replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount; @@ -1306,8 +1306,8 @@ namespace YLErp.Modules.SwapModule var finalCloseInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { firstCloseEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -1434,8 +1434,8 @@ namespace YLErp.Modules.SwapModule var partial = service.GetInterests( td, td.trade_extend, partialCloseDate, partialCloseDate, new List { previousEod }, new List { position }, - notional, notional, 0m, partialNotional, partialPercent, - (int)SwapEventTypeEnum.平仓, false, false, 0m, notional, + notional, partialNotional, partialPercent, + (int)SwapEventTypeEnum.平仓, false, notional, settment: false).Single(); AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), @@ -1444,8 +1444,8 @@ namespace YLErp.Modules.SwapModule var final = service.GetInterests( td, td.trade_extend, maturityDate, maturityDate, new List(), new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false, newCalcLast: true).Single(); AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), @@ -1575,8 +1575,8 @@ namespace YLErp.Modules.SwapModule var intermediateInterest = dealService.GetInterests( td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + remainingNotional, remainingNotional, 1m, + (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}"); @@ -1706,8 +1706,8 @@ namespace YLErp.Modules.SwapModule var intermediateInterest = dealService.GetInterests( td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + remainingNotional, remainingNotional, 1m, + (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}"); @@ -1739,8 +1739,8 @@ namespace YLErp.Modules.SwapModule var finalInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { intermediateEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: false).Single(); AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount, "0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额"); @@ -1829,8 +1829,8 @@ namespace YLErp.Modules.SwapModule var result = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { previousEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false).Single(); AssertDecimal(expectedInterest, result.InterestAmount, @@ -1927,8 +1927,8 @@ namespace YLErp.Modules.SwapModule var partialInterest = dealService.GetInterests( td, td.trade_extend, partialCloseDate, partialCloseDate, new List { preCloseEod }, new List { position }, - originalNotional, originalNotional, 0m, partialNotional, partialClosePercent, - (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + originalNotional, partialNotional, partialClosePercent, + (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount, $"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列"); @@ -1976,8 +1976,8 @@ namespace YLErp.Modules.SwapModule var finalInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { finalPreEod }, new List { position }, - remainingNotional, remainingNotional, 0m, remainingNotional, 1m, - (int)SwapEventTypeEnum.平仓, false, false, 1m, remainingNotional, + remainingNotional, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount, $"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列"); diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs new file mode 100644 index 00000000..bb9af66d --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -0,0 +1,291 @@ +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.EodModule; + +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 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 数据(不连库)。 + 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); + } + + /// 真实 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 + { + 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) + { + // 桥接真实生产口径: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 }; + 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 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}"); + + #endregion + + /// + /// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。 + /// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。 + /// + /// 序列(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(BondPayments()); + 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(不丢失)"); + } + + /// + /// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红 + /// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。 + /// + /// 系统行为:①盘中 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(BondPayments()); + 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"); + } + + /// + /// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让 + /// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。 + /// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码 + /// (originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)—— + /// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。 + /// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。 + /// + [TestMethod] + public void 脏数据边界_死代码涉及字段_不影响EOD分红产出() + { + // 正常数据基准 + var eodSvcClean = new EodSvcStub(BondPayments()); + var tdClean = CreateTrade(); + var positionClean = CreatePosition(); + var initialEod = CreateInitialEod(); + var d1 = new DateTime(2026, 1, 6); + var d2 = new DateTime(2026, 1, 7); + var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate); + var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1, + new List { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) }); + + // 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证) + var eodSvcDirty = new EodSvcStub(BondPayments()); + var tdDirty = CreateTrade(); + tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点 + var positionDirty = CreatePosition(); + positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点 + var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate); + var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1, + new List { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) }); + + // 脏数据不崩 + 输出与正常数据逐字段一致 + AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响"); + AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响"); + Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)"); + } + } +} 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/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs index bb29f270..398c8b3a 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260703CloseInterestTest.cs @@ -209,10 +209,10 @@ namespace YLErp.Modules.SwapModule CloseDate, CloseDate, // valueDate / unwindDate new List(), // eodPositions(空) new List { position }, - Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional + Notional, Notional, // posiNotional / 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 new file mode 100644 index 00000000..f614e93c --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -0,0 +1,470 @@ +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; + } + // 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为, + // 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + => 0m; + } + + 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%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。 + /// + /// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs + /// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。 + /// 修复(契约修复§六):EOD 普通当日平仓重算(autoSwap=false)改传 Intraday 形状 + /// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。 + /// 依据:项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md(契约公式唯一确定应结=平掉额×全程参考利率)。 + /// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。 + /// + [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(InterestCalcRequest.IntradayUnwind( + td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, + PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); + + // 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3) + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: false, 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}"); + + // 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率") + Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)"); + // 手算锚点(300×[(1+0.011×3/365)×(1+0.011×1/365)−1],与裁决文档§二玩具参数一致) + Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero), + "盘中重放=契约公式手算锚点 0.036165"); + } + + /// + /// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。 + /// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。 + /// + [TestMethod] + public void 复利_mode2_部分平仓_观察日恒1语义保持() + { + 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 observationDay = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: true, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, observationDay.Count); + Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m, + "观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值"); + } + + /// + /// 单利×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(InterestCalcRequest.IntradayUnwind( + td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, + PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); + + // 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3) + var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, Closed, ClosePercent, + (int)SwapEventTypeEnum.平仓, tdClose: false, 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.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)"); + Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0"); + } + + /// + /// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1): + /// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——裁决§五.2 维持)。 + /// + [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 }; + + // 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支) + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, PreClose, PreClose, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, 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 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)"); + } + + #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 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, + 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 + + #region 守恒不变量(§7-1, 免 oracle/免库, 守 EOD平仓后收盘×部分平仓 裸格) + + // 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/ + // InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关, + // 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。 + // 全部内存构造(StubSwapDealService 避库);funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。 + + /// + /// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。 + /// + private static eod_swap_position NewPreEod(decimal carryPrincipal) + => new() + { + id = 0, SwapTradeId = 1, PositionId = 1001, + ValueDate = new DateTime(2026, 4, 29), ClientId = 999998, + FloatRate = 0m, TdInterestPrincipal = carryPrincipal, + PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m + }; + + /// + /// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue, + /// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。 + /// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。 + /// + [TestMethod] + public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余 + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件"); + // 码算:引擎把剩余持仓前递(return 700) + Assert.AreEqual(Remaining, preEod.PosiNotionalValue, + "EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏"); + // 守恒:期初 = 前递剩余(码算) + 平掉额(输入) + Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed, + "期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错"); + } + + /// + /// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。 + /// + [TestMethod] + public void EOD平仓后收盘_全平_守恒_剩余前递归零() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + var preEod = NewPreEod(0m); + var eodPositions = new List { preEod }; + var positions = new List { position }; + + var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + eodPositions, positions, 0m, PreClose, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(0m, preEod.PosiNotionalValue, + "全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏"); + Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose, + "全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)"); + } + + /// + /// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额, + /// 构成跨日携带链守恒。Day1 期初1000→平300剩700;Day2 期初700→平210剩490;累计平掉510+剩余490=1000。 + /// + [TestMethod] + public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额() + { + var td = CreateTrade(); + var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); + + // Day1:期初1000,平300,剩700 + var preEod1 = NewPreEod(PreClose); + var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + new List { preEod1 }, new List { position }, + Remaining, Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, + add: false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, result1.Count); + Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700"); + + // Day2:期初=Day1剩余700,平210,剩490 + const decimal day2OrginPv = 700m; + const decimal day2Closed = 210m; + const decimal day2Remaining = 490m; + var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700 + var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + new List { preEod2 }, new List { position }, + day2Remaining, day2Closed, 1m, + (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: day2OrginPv, + add: false, settment: false, newCalcLast: false, closeList: null); + + Assert.AreEqual(1, result2.Count); + // 码算:Day2 剩余前递=当日剩余(490) + Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)"); + // 逐日守恒:期初 - 剩余前递 = 平掉额(210) + Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue, + "Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错"); + } + + /// + /// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。 + /// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5; + /// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。 + /// + [TestMethod] + public void 多次平仓_占期初累计比例等于各次剩余口径连乘补数() + { + var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m); + Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3"); + var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m); + Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5"); + + var cumulativeClosed = 1m - (1m - b1) * (1m - b2); + Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m, + "多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂"); + + var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m); + Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs index cb664f25..7a590d08 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T0.cs @@ -228,9 +228,9 @@ namespace YLErp.Modules.SwapModule var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate); var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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]; } @@ -244,9 +244,9 @@ namespace YLErp.Modules.SwapModule var position = CreateFloatInterestPosition(interestRule, interestType, fixedRate); var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate, eodPositions, new List { position }, - Principal, Principal, Principal, Principal, 1m, + 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]; } @@ -263,9 +263,9 @@ namespace YLErp.Modules.SwapModule var position = CreateFixedInterestPosition(fixedRate, interestRule); var interests = _service.GetInterests(td, td.trade_extend, valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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]; } @@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule var position = CreateFixedInterestPosition(fixedRate, interestRule); var interests = _service.GetInterests(td, td.trade_extend, valueDate, valueDate, eodPositions, new List { position }, - Principal, Principal, Principal, Principal, 1m, + 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..93a26203 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsUnitTest_T1.cs @@ -322,9 +322,9 @@ namespace YLErp.Modules.SwapModule valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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]; @@ -346,9 +346,9 @@ namespace YLErp.Modules.SwapModule valueDate, valueDate, eodPositions, new List { position }, - Principal, Principal, Principal, Principal, 1m, + 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]; @@ -371,9 +371,9 @@ namespace YLErp.Modules.SwapModule valueDate, valueDate, eodPositions, new List { position }, - Principal, Principal, Principal, Principal, closePercent, + 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]; @@ -407,9 +407,9 @@ namespace YLErp.Modules.SwapModule valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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]; @@ -430,9 +430,9 @@ namespace YLErp.Modules.SwapModule valueDate, valueDate, eodPositions, new List { position }, - Principal, Principal, Principal, Principal, 1m, + 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]; @@ -1716,9 +1716,9 @@ namespace YLErp.Modules.SwapModule valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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]; @@ -1747,9 +1747,9 @@ namespace YLErp.Modules.SwapModule valueDate, unwindDate, eodPositions, new List { position }, - posiNotional, posiNotional, posiNotional, posiNotional, closePercent, + 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..e9816011 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginInterestGoldenReplayTest.cs @@ -99,9 +99,9 @@ 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, + 0m, 0m, 1.0m, + (int)SwapEventTypeEnum.自动互换, tdClose: false, + orginPv: 0m, add: false, settment: true, newCalcLast: false, closeList: null); } catch (Exception ex) 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/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/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs index edf94c89..7732e13b 100644 --- a/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs +++ b/UnitTestProject/Modules/SwapModule/Margin/MarginModesTest.cs @@ -8,7 +8,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin { /// /// MarginModes 统一判断口径测试。 - /// 验证它和现有散落的 marginTypes/InterestMarginModels/premiumModes 内容一致。 + /// 验证 MarginModes 由框架常量 ConsTrade.InterestMarginModels 派生,内容一致。 /// [TestClass] public class MarginModesTest @@ -37,7 +37,7 @@ namespace UnitTestProject.Modules.SwapModule.Margin Assert.IsFalse(MarginModes.Contains((int)InterestModeEnum.标的期初全价)); } - /// 守护:和 ConsTrade.InterestMarginModels 内容必须一致(迁移期对齐)。 + /// 回归护栏:MarginModes 由 ConsTrade.InterestMarginModels 派生,内容须一致(防止有人又独立重写集合导致口径分裂)。 [TestMethod] public void 与ConsTradeInterestMarginModels内容一致() { diff --git a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs index 1dfb1d64..8ea6d60b 100644 --- a/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs +++ b/UnitTestProject/Modules/SwapModule/MultiStepConservationTest.cs @@ -119,8 +119,8 @@ namespace YLErp.Modules.SwapModule var position = CreateInterestPosition(); 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); return interests.Count > 0 ? interests[0].InterestAmount : 0m; } @@ -142,8 +142,8 @@ 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: true, newCalcLast: false); if (interests.Count == 0) return (0m, 0m); return (interests[0].TdInterestAmount, interests[0].InterestAmount); @@ -313,8 +313,8 @@ namespace YLErp.Modules.SwapModule var svc5 = new StubDealService(0m, floatRate: 0.001); 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal swap1 = i5.Count > 0 ? i5[0].InterestAmount : 0m; @@ -322,8 +322,8 @@ namespace YLErp.Modules.SwapModule var svc10 = new StubDealService(swap1, floatRate: 0.001); 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal swap2 = i10.Count > 0 ? i10[0].InterestAmount : 0m; @@ -332,8 +332,8 @@ namespace YLErp.Modules.SwapModule var svc15 = new StubDealService(totalConsumed, floatRate: 0.001); 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, + Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, Principal, settment: false); decimal finalUnwind = i15.Count > 0 ? i15[0].InterestAmount : 0m; @@ -362,8 +362,8 @@ namespace YLErp.Modules.SwapModule var svc = new StubDealService(0m, floatRate: 0.001); 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, + Principal, Principal, 1m, + (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..37c50207 100644 --- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs +++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalCloseTraceTest.cs @@ -103,8 +103,8 @@ namespace YLErp.Modules.SwapModule SwapCalcTrace.Reset(); 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, + new List { pos }, PrepayFix, PrepayFix, 1m, + (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..ad3a7182 100644 --- a/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs +++ b/UnitTestProject/Modules/SwapModule/PrepaidPrincipalClosingChainTraceTest.cs @@ -101,9 +101,9 @@ namespace YLErp.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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/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}"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs index f10e3439..0d6afc17 100644 --- a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs @@ -80,9 +80,9 @@ namespace YLErp.Modules.SwapModule var result = service.GetInterests( trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate, new List { previousEod }, new List { position }, - closeCase.RemainingNotional, closeCase.RemainingNotional, 0m, + closeCase.RemainingNotional, 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/SwapEodPositionServiceIntegrationTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs index ee7235e3..b4ab66bf 100644 --- a/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Reflection; using YLErp.DBModels.Enums; @@ -272,7 +273,16 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($" ✓ {scenario.Scenario}"); } - Assert.AreEqual(13, parameters.Length, "DealInterests应有13个参数"); + // 校验参数集合(按名称,对参数增删/重排/改名均敏感,比裸数字更稳) + var expectedParamNames = new[] + { + "interestList", "eodPositions", "todyEodPositions", "settleDate", + "td", "flowEvents", "autoInterests", "lastEodSwap", + "posiTotalNotional", "closeNational", "grossPrice", "orginPv" + }; + var actualParamNames = parameters.Select(p => p.Name).ToArray(); + CollectionAssert.AreEquivalent(expectedParamNames, actualParamNames, + "DealInterests 参数集合应与预期一致(新增/重排/改名参数时请同步更新此列表)"); Console.WriteLine("✅ 分支覆盖分析完成"); } } diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs index bf3f2b0a..9d1e4c59 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario1And2Test.cs @@ -56,17 +56,17 @@ namespace UnitTestProject.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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); + eodPositions, positions, posiNotionalValue, + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate, @@ -74,7 +74,7 @@ namespace UnitTestProject.Modules.SwapModule List flowEvents, decimal closeNotional, eod_swap_position prevEod) { SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null, - posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m, + posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m, posiLongNotional + posiShortNotional); return PersistedPositions.LastOrDefault(); } @@ -211,9 +211,9 @@ namespace UnitTestProject.Modules.SwapModule var interests = svc.GetInterests( td, td.trade_extend, valueDate, valueDate, prevEod, new List { position }, - closeNotional, closeNotional, 0m, closeNotional, 1m, + closeNotional, 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..5bc338e8 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -178,17 +178,17 @@ namespace UnitTestProject.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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); + eodPositions, positions, posiNotionalValue, + 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; @@ -226,7 +226,7 @@ namespace UnitTestProject.Modules.SwapModule List flowEvents, decimal closeNotional, eod_swap_position prevEod) { SaveAutoEodWithCloseInterestPosition(prevEod, null, position, _td, valueDate, null, - posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m, + posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m, posiLongNotional + posiShortNotional); return PersistedPositions.LastOrDefault(); } @@ -394,9 +394,9 @@ namespace UnitTestProject.Modules.SwapModule var interests = svc.GetInterests( td, td.trade_extend, valueDate, valueDate, prevEod, new List { position }, - closeNotional, closeNotional, 0m, closeNotional, 1m, + closeNotional, 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..708ac5cf 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -79,16 +79,16 @@ namespace YLErp.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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); + eodPositions, positions, posiNotionalValue, + 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..ceb2976d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapSingleTradeVerificationTest.cs @@ -59,17 +59,17 @@ namespace UnitTestProject.Modules.SwapModule 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, bool needPrice, - decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, + decimal posiNotionalValue, + 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); + eodPositions, positions, posiNotionalValue, + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate, @@ -77,7 +77,7 @@ namespace UnitTestProject.Modules.SwapModule List flowEvents, decimal closeNotional, eod_swap_position prevEod) { SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null, - posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m, + posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m, posiLongNotional + posiShortNotional); return PersistedPositions.LastOrDefault(); } @@ -213,9 +213,9 @@ namespace UnitTestProject.Modules.SwapModule var interests = svc.GetInterests( td, td.trade_extend, valueDate, valueDate, prevEod, new List { position }, - closeNotional, closeNotional, 0m, closeNotional, 1m, + closeNotional, 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..cf01b63b 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -93,9 +93,9 @@ namespace YLErp.Modules.SwapModule var position = MakePrepayPosition(); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, new List { position }, - UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, UnderlyingNotional, closePercent, + 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]; } @@ -111,9 +111,9 @@ namespace YLErp.Modules.SwapModule var position = MakePrepayPosition(fix, rate); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, new List { position }, - notional, notional, notional, notional, closePercent, + 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]; } @@ -279,9 +279,9 @@ namespace YLErp.Modules.SwapModule }; var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, eod, new List { position }, - fix, fix, fix, fix, closePercent, + 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]; } @@ -373,9 +373,9 @@ namespace YLErp.Modules.SwapModule // orginPv 传 notional:非预付金腿不走 877-881 的 Fix 对齐,dynomicPrincipal = notional + notional - notional = notional var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, eod, new List { position }, - notional, notional, notional, notional * closePercent, closePercent, + 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]; } @@ -488,9 +488,9 @@ namespace YLErp.Modules.SwapModule }; var interests = _svc.GetInterests(td, td.trade_extend, ProdUnwindDate, ProdUnwindDate, eodPos, new List { position }, - baseP, baseP, baseP, baseP * closePercent, closePercent, + 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..5e03e9c6 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs @@ -121,9 +121,9 @@ namespace YLErp.Modules.SwapModule var position = MakePosition(currentNotional); var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, MakeLastEod(), new List { position }, - currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent, + 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/UnitTestProject/Program.cs b/UnitTestProject/Program.cs index 0f768067..708646cf 100644 --- a/UnitTestProject/Program.cs +++ b/UnitTestProject/Program.cs @@ -30,9 +30,24 @@ namespace YLErp YLServiceLocator.SetServiceCollection(services); - AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration); - - DataCacheManager.UpdateOnce(); + // P0 容错(2026-08-16):初始化段(AppManager.Initialize 内部 InitializePsConfig 读 AppConfig 表、 + // DataCacheManager.UpdateOnce 预热)在测试库不可达时不再让 ModuleInitializer 抛异常连坐全部 903 个 + // 测试——降级为醒目警告,纯内存测试照常可跑;依赖配置/缓存/库的测试将以各自的连接错误失败 + // (与降级前表现一致,只是不再全红归因到"类创建失败")。完全跳过初始化仍用 + // YLErp_UNIT_TEST_SKIP_INITIALIZATION=1。实测触发链:M1→AppManager.Initialize→ConfigDic→ + // ServerVersion.AutoDetect→MySQL 不可达(Program.cs:33,2026-08-16 栈实证)。 + try + { + AppManager.Initialize(YLErp.Enums.SubSystemName.UnitTest, configuration); + DataCacheManager.UpdateOnce(); + } + catch (Exception ex) + { + var warn = $"[UnitTest初始化降级] 初始化段失败(测试库不可达?):{ex.GetType().Name}: {ex.Message}。" + + "纯内存测试继续;依赖配置/缓存/数据库的测试将失败——这是网络问题不是代码问题。"; + Console.WriteLine(warn); + logger.Warn(warn); + } services.AddHttpClient("") .ConfigurePrimaryHttpMessageHandler(messageHandler => 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/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/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/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/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 3e89b5b4..549af3ad 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.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/EodModule/SettlementModule/EodSettlementService.cs b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs index 83a6007e..26f22bfa 100644 --- a/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs +++ b/YLErpDAL/Modules/EodModule/SettlementModule/EodSettlementService.cs @@ -39,14 +39,15 @@ namespace YLErp.Modules.EodModule predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate); } + // 除权数据不在这里做 SQL 左连接:同一标的一天只允许一条有效除权记录, + // 但历史脏数据可能存在重复行。左连接会把一条 EOD 持仓扩成多行,进而重复 + // 参与后续风险/结算计算。先取得 EOD+BOD 的唯一持仓结果,再按标的代码匹配 + // 除权记录,可以把重复业务键暴露为 ToDictionary 异常,而不是静默扩行。 var query = from eod in DbContext.Set().Where(predicate) join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate) on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty } equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod from bod in t_bod.DefaultIfEmpty() - join dividend in DbContext.ex_dividend_info.Where(O => O.ExDividendDate == settleDate && O.ValidStatus) - on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend - from dividend in t_dividend.DefaultIfEmpty() select new { eod, @@ -55,24 +56,31 @@ namespace YLErp.Modules.EodModule bod.Amount, bod.Cost, //bod.AveragePrice - }, - dividend + } }; var datas = query.ToArray(); var diviService = new TradeModule.DealModule.DividendService(OptUser); + // 除权查询集中复用 DividendService 的有效记录条件。字典使用不区分大小写的 + // UnderlyingCode 匹配,兼容 EOD 与除权表代码大小写差异;如果同日同代码仍有 + // 多条有效记录,ToDictionary 会失败,提示迁移/结算前先清理重复数据。 + var dividendDict = diviService.GetExDividendQuery(settleDate) + .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase); var eodPriceProvider = new EodPriceProvider(settleDate); return datas.Select(data => { var eod = data.eod; var bod = data.bod; - if (data.dividend != null) + // 命中除权数据后仍沿用原有股票结算分支:只重算除权后的收盘价和数量, + // 并保留原 Pv 的正负方向。其他 TradeType 当前不进入该分支,避免扩大 + // 本次查询重构的业务范围。 + if (dividendDict.TryGetValue(eod.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") { var SettlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice); - SettlePrice = diviService.GetPrice(SettlePrice, data.dividend); - var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend); + SettlePrice = diviService.GetPrice(SettlePrice, dividend); + var amount = diviService.GetPositionAmount(data.eod.Amount, dividend); eod.Pv = eod.Pv > 0 ? Math.Abs(amount * SettlePrice) : -Math.Abs(amount * SettlePrice); } } @@ -104,14 +112,14 @@ namespace YLErp.Modules.EodModule predicate = PredicateBuilder.Create(n => n.ValueDate == settleDate).And(predicate); } + // 带风险数据的重载与上面的持仓重载采用相同策略:除权记录不参与 SQL 左连接, + // 先完成 EOD、BOD、Risk 的行级关联,再在内存中按标的代码查找唯一除权记录, + // 防止除权表重复行复制风险记录。 var query = from eod in DbContext.Set().AsNoTracking().Where(predicate) join bod in DbContext.BodTradePosition.Where(n => n.ValueDate == bodDate) on new { eod.BookId, eod.TradeType, eod.PositionType, eod.UnderlyingCode, ExchangeOptionCode = eod.ExchangeOptionCode ?? string.Empty } equals new { bod.BookId, bod.TradeType, bod.PositionType, bod.UnderlyingCode, ExchangeOptionCode = bod.ExchangeOptionCode ?? string.Empty } into t_bod from bod in t_bod.DefaultIfEmpty() - join dividend in DbContext.ex_dividend_info.AsNoTracking().Where(O => O.ExDividendDate == settleDate && O.ValidStatus) - on eod.UnderlyingCode equals dividend.UnderlyingCode into t_dividend - from dividend in t_dividend.DefaultIfEmpty() join risk in DbContext.Set().AsNoTracking().Where(n => n.ValueDate == settleDate && n.TradeId > 0) on new { eod.ValueDate, eod.TradeId } equals new { risk.ValueDate, risk.TradeId } into risk_t from risk in risk_t.DefaultIfEmpty() select new @@ -123,24 +131,28 @@ namespace YLErp.Modules.EodModule bod.Cost, //bod.AveragePrice }, - dividend, risk }; var datas = query.ToArray(); var diviService = new TradeModule.DealModule.DividendService(OptUser); + // 与无风险重载保持同一数据来源、日期条件和大小写无关的代码匹配规则;重复 + // 有效记录会在这里显式失败,而不是让一条持仓对应多条风险结果。 + var dividendDict = diviService.GetExDividendQuery(settleDate) + .ToDictionary(O => O.UnderlyingCode, O => O, StringComparer.OrdinalIgnoreCase); var eodPriceProvider = new EodPriceProvider(settleDate); return datas.Select(data => { var pos = data.eod; var bod = data.bod; - if (data.dividend != null) + // 风险对象的除权 Pv 重算规则与上一个重载保持一致,仅在股票交易类型下执行。 + if (dividendDict.TryGetValue(pos.UnderlyingCode, out var dividend)) { if (data.eod.TradeType == "股票") { var settlePrice = eodPriceProvider.GetPrice(data.eod.UnderlyingCode, SettlementTypeEnum.ClosePrice); - settlePrice = diviService.GetPrice(settlePrice, data.dividend); - var amount = diviService.GetPositionAmount(data.eod.Amount, data.dividend); + settlePrice = diviService.GetPrice(settlePrice, dividend); + var amount = diviService.GetPositionAmount(data.eod.Amount, dividend); pos.Pv = pos.Pv > 0 ? Math.Abs(amount * settlePrice) : -Math.Abs(amount * settlePrice); } } diff --git a/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md b/YLErpDAL/Modules/SwapModule/ARCHITECTURE.md index 43f2b474..669e64ff 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 标的端汇总值 @@ -66,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) @@ -76,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 契约) ``` @@ -132,7 +140,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/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/EodSwapPositionQueries.cs b/YLErpDAL/Modules/SwapModule/EodSwapPositionQueries.cs new file mode 100644 index 00000000..7041def4 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/EodSwapPositionQueries.cs @@ -0,0 +1,19 @@ +using System; +using System.Linq; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// eod_swap_position 查询收口(Query Object)。 + /// 规则"某交易某日日终的有效持仓 = SwapTradeId 匹配 + ValueDate 匹配 + 未作废(!Invalid)"集中于此, + /// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。 + /// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。 + /// + public static class EodSwapPositionQueries + { + public static IQueryable ActiveByTradeAndDate( + this IQueryable query, int tradeId, DateTime valueDate) + => query.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid); + } +} 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/InterestCalcRequest.cs b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs new file mode 100644 index 00000000..fcaff397 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/InterestCalcRequest.cs @@ -0,0 +1,85 @@ +namespace YLErp.Modules.SwapModule; + +/// +/// GetInterests 参数对象(2026-08 参数显式化)。 +/// +/// 动机:原 GetInterests 20 个位置参数中,名义本金簇(posiNotionalValue/closePosiNotionalValue/closePercent) +/// 在【盘中平仓】与【EOD 平仓后收盘】两类场景下语义相反(详见 GetInterests "根因位置"注释与 +/// GetInterestsEntrySemanticsTest 的口径留档),位置参数无法表达该约束。 +/// +/// 用法:只能经两个场景工厂构造——工厂形参名即该场景语义(平仓前剩余 / 平仓后剩余 / 实际平掉额), +/// 物理上防止两套语义混传。needPrice/grossPrice(原方法死参数)与 posiLong/posiShortNotionalValue +/// (多空组合子系统删除后计息链零消费的管道死参数)均不承载。 +/// +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; } + + /// 本次实际平掉本金(两场景恒同义)。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 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; 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 closedNotional, decimal closePercentRemaining, + int eventType, bool tdClose, decimal orginPv, + bool add, bool newCalcLast, List closeList) + => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + preCloseNotional, 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 closedNotional, + int eventType, bool tdClose, decimal orginPv, + bool add, bool newCalcLast) + => new(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, + remainingNotionalAfterClose, closedNotional, 1m, // 恒1:本次事件全额结息(非 closeNational / 期初比例) + eventType, tdClose, orginPv, add, newCalcLast, closeList: null); +} 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; -} diff --git a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs index 359e3fc9..99346d4b 100644 --- a/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs +++ b/YLErpDAL/Modules/SwapModule/Margin/MarginModes.cs @@ -7,14 +7,18 @@ namespace YLErp.Modules.SwapModule.Margin; /// /// 保证金计息模式(mode 5 初始预付金 / mode 6 追加预付金)的统一判断口径。 /// -/// 现状(待收敛):同一集合 {初始预付金, 追加预付金} 在代码里复制了至少 6 次—— -/// ConsTrade.InterestMarginModels(框架级) -/// SwapEodPositionService.marginTypes(实例字段) -/// SwapEodPositionService.premiumModes(局部变量) -/// SwapEventEmailService.marginTypes -/// EodClientBalanceCalc.marginTypes -/// ClientBalanceUtility.marginTypes -/// 任何一处漏改(如新增保证金形态)都会导致口径分裂。本类收敛到单一来源。 +/// 依赖方向:本类位于 YLErpDAL 层,单一真源是框架层常量 +/// (YLErp.DBModels)。Core 不能反向依赖 DAL, +/// 故本类的集合直接由该框架常量派生(new HashSet/List),而非独立重写—— +/// 任何一处要新增保证金形态,只需改 ConsTrade.InterestMarginModels 即全局生效。 +/// +/// 收敛历史:早期同一集合 {初始预付金, 追加预付金} 在代码里被复制多次 +/// (ConsTrade.InterestMarginModels / SwapEodPositionService.marginTypes / +/// SwapEodPositionService.premiumModes / SwapEventEmailService.marginTypes / +/// EodClientBalanceCalc.marginTypes / ClientBalanceUtility.marginTypes)。 +/// 现余额/邮件/利息等入口已改用本类;SwapEodPositionService.premiumModes 局部变量 +/// 也已替换为 MarginModes.ForLinq。ConsTrade.InterestMarginModels 作为框架级常量保留 +/// (它是唯一真源,并非冗余)。 /// /// 注意:这里的"保证金 mode"是现有系统把保证金错误建模为计息腿的历史遗留。 /// 按 Margin 限界上下文的设计方向,未来保证金不应用 InterestMode 标识, @@ -22,34 +26,24 @@ namespace YLErp.Modules.SwapModule.Margin; /// public static class MarginModes { - /// 所有属于保证金的 InterestMode(初始预付金 / 追加预付金)。 - public static readonly IReadOnlyCollection All = new HashSet - { - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, - }; + /// 所有属于保证金的 InterestMode(派生自 ConsTrade.InterestMarginModels)。 + public static readonly IReadOnlyCollection All = new HashSet(ConsTrade.InterestMarginModels); /// /// List 形态,供 EF Core LINQ 表达式用(HashSet.Contains 无法翻译成 SQL)。 - /// 替代 ConsTrade.InterestMarginModels。 + /// 内容派生自框架常量 ConsTrade.InterestMarginModels(单一真源),本类仅做形态适配。 /// - public static readonly List ForLinq = new() - { - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, - }; + public static readonly List ForLinq = new List(ConsTrade.InterestMarginModels); /// 判断 mode 是否属于保证金(非 LINQ 场景用)。 public static bool Contains(int interestMode) => All.Contains(interestMode); /// 固定值 + 保证金 mode 集合(固定值/初始预付金/追加预付金)。 /// 用于 EOD 场景判断"计息基数取 InterestPrincipalFix 而非持仓名义本金"的腿。 - /// 替代 SwapEodPositionService 中 3 处内联 new List{固定值, 初始预付金, 追加预付金}。 - public static readonly IReadOnlyCollection FixedAmountAndMargin = new HashSet + /// 保证金部分派生自 ConsTrade.InterestMarginModels,固定值额外并入。 + public static readonly IReadOnlyCollection FixedAmountAndMargin = new HashSet(ConsTrade.InterestMarginModels) { (int)InterestModeEnum.固定值, - (int)InterestModeEnum.初始预付金, - (int)InterestModeEnum.追加预付金, }; /// 判断 mode 是否为固定值或保证金。 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 13cf78d8..229f4c1b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1,9 +1,8 @@ -using MoreLinq.Extensions; +using MoreLinq.Extensions; 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) @@ -211,7 +210,7 @@ namespace YLErp.Modules.SwapModule public UnwindData InitUnwind(int tradeId) { var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = DbContext.swap_position.ActiveByTrade(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType); List eventTyps = new List() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 }; @@ -277,6 +276,7 @@ namespace YLErp.Modules.SwapModule // DividendPending = "待结算分红收益"(仍挂在账上、未来才结的存量 = PosiDividendSum 全量口径, // 见 GetPreEodDividendSum 注释的口径论证;切勿改回硬0或分摊,会落库回归) decimal preEodDividendSum = GetPreEodDividendSum(tradeId, position.PositionId, dealDate); + 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; @@ -355,7 +355,7 @@ namespace YLErp.Modules.SwapModule { var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = DbContext.swap_position.ActiveByTrade(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; var maxIncomeValueDate = GetMaxIncomeValueDate(td); @@ -410,7 +410,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.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; floatEvent.CloseFee = 0; @@ -458,23 +460,18 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - var allpositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList(); + var allpositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList(); var origPositions = allpositions.Where(x => x.IsInitial).ToList(); var realPostitions = allpositions.Where(x => !x.IsInitial).ToList(); // 根因修复(多次部分平仓预付金返还错误):见 ResolveInterestLegPositions 注释。 // 迭代源仍用 origPositions(保留 orig.id → eod_swap_position.PositionId 的日终匹配), // 仅对预付金腿以实时腿的剩余本金克隆覆盖,故此处不改任何日终匹配行为。 var positions = ResolveInterestLegPositions(origPositions, realPostitions); - var fpositions = origPositions.Where(x => x.PosiDirection > 0).ToList(); - var longPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Long).ToList(); - var shortPositions = fpositions.Where(x => x.PositionType == (int)PositionTypeFlag.Short).ToList(); var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var lastEod = DbContext.eod_swap.Where(x => x.ValueDate < unwindDate && x.SwapTradeId == tradeId).OrderByDescending(o => o.ValueDate).FirstOrDefault(); var _preSetteDate = lastEod == null ? unwindDate.AddDays(-1) : lastEod.ValueDate; List lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据 - var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金 - var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金 var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金 var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金 var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金 @@ -484,7 +481,11 @@ 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); + // 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind) + interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( + td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, + stockEqvNotional, posiNotionalValue, + closePercent, eventType, tdClose, orginPv, add: true, newCalcLast: false, closeList)); return interests; } @@ -606,14 +607,22 @@ namespace YLErp.Modules.SwapModule /// 上一日终持仓 /// 期初利率端 /// 持仓名义本金 - /// 多头持仓名义本金 - /// 空头持仓名义本金 /// 平仓名义本金 /// /// /// /// /// + /// + /// 【盘中平仓/互换结息】显式入口——GetInterests(settment:false) 盘中语义的具名封装(2026-08 显式化重构)。 + /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。 + /// + public List GetIntradayUnwindInterests(InterestCalcRequest req) + => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, + req.PosiNotionalValue, req.ClosePosiNotionalValue, + req.ClosePercent, req.EventType, req.TdClose, + req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); + public List GetInterests( trade td, trade_extend tradeExtend, @@ -622,14 +631,10 @@ 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, bool add = false, bool settment = true, @@ -652,27 +657,30 @@ namespace YLErp.Modules.SwapModule // true 跳过 不计利息; false 正常利息 bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate); - // 计算名义本金 - decimal closePrincipal; - decimal posiPrincipal; - decimal newClosePercent = closePrecent; - var mode = (InterestModeEnum)position.InterestMode; + // 获取利率(保证金/融资腿共用:SwapIntervalList 取当日适用固定利率 + 精度收口) + decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差 + // ── 边界隔离:保证金腿(5/6)在循环最外层路由,后续融资腿分支树不感知保证金概念 ── + // 有意跳过 GetFloatRate:CalcMarginInterest 纯固定利率(FundingLegRate.Fixed)且 FloatRate 恒 0, + // 浮动取价/回写对保证金无意义;即使脏数据填了 FloatRateUnderlyingCode 且缺价,也不应阻断保证金结算。 if (MarginModes.Contains(position.InterestMode)) { - // 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额) - closePrincipal = position.InterestPrincipalFix * closePrecent; - posiPrincipal = position.InterestPrincipalFix; - } - else - { - // 融资腿(1/2/9): 走策略工厂 - var r = FundingLegStrategyFactory.Get(mode) - .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent); - closePrincipal = r.ClosePrincipal; - posiPrincipal = r.PosiPrincipal; - newClosePercent = r.ClosePercent; + positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); + // 保证金腿: 计息基数 = InterestPrincipalFix(保证金余额),无融资腿差分公式与 orginPv 维度 hack + interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, + position.InterestPrincipalFix * closePrecent, position.InterestPrincipalFix, + closePrecent, annualDays, calcFirst, calcLast || newCalcLast, preEodPosition, eventType, add, settment, swap)); + continue; } + + // 计算名义本金(以下仅融资腿 1/2/9:走策略工厂) + var mode = (InterestModeEnum)position.InterestMode; + var r = FundingLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePrecent); + decimal closePrincipal = r.ClosePrincipal; + decimal posiPrincipal = r.PosiPrincipal; + decimal newClosePercent = r.ClosePercent; + // 根因位置:SwapEodPositionService.SaveAutoEodWithCloseInterestPosition 在平仓后收盘时传入 // “收盘后剩余本金 + closePercent=1”,与盘中“平仓前本金 + 实际关闭比例”不是同一语义。 // GetInterests 同时被盘中试算和 EOD 平仓后收盘调用:后者传入的 @@ -681,29 +689,17 @@ namespace YLErp.Modules.SwapModule // 模式2(合约名义本金规模)的本次结息本金必须始终是实际平仓额,因此无条件覆盖, // 否则会错误地用剩余 70 结算本次平掉的 30。模式9(标的期初全价)的部分平仓 // 仍保留既有的剩余/复利动态本金承接逻辑;仅最终全平时 posi=0,才覆盖以避免结息本金为 0。 - if ((InterestModeEnum)position.InterestMode == InterestModeEnum.合约名义本金规模 - || ((InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价 + if (mode == InterestModeEnum.合约名义本金规模 + || (mode == InterestModeEnum.标的期初全价 && posiNotionalValue == 0m)) { closePrincipal = closePosiNotionalValue; } - if (MarginModes.Contains(position.InterestMode)) - { - positionClone.InterestDirection = MarginCalc.FlipDirection(position.InterestDirection); - } - // 获取利率 - decimal rate = Math.Round(GetFixedRate(position, unwindDate), InterestCalculationPrecision, MidpointRounding.AwayFromZero); // 做精度调整 原数据有精度误差 decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone); // 根据场景计算利息 - if (MarginModes.Contains(position.InterestMode)) - { - // 保证金腿(5/6):专属计息,notional 直接取保证金余额,无融资腿差分公式与 orginPv 维度 hack - interests.Add(CalcMarginInterest(td, valueDate, endDate, positionClone, rate, closePrincipal, posiPrincipal, - newClosePercent, annualDays, calcFirst, calcLast||newCalcLast, preEodPosition, eventType, add, settment, swap)); - } - else if (settment) + if (settment) { // 收盘归档场景,使用 CalcEodInterest interests.Add(CalcEodInterest(td, valueDate, positionClone, rate, floatRate, closePrincipal, posiPrincipal, annualDays, calcFirst, calcLast, preEodPosition, eventType, add)); @@ -717,7 +713,7 @@ namespace YLErp.Modules.SwapModule : 0m; interests.Add(CalcUnwindInterest(td, valueDate, endDate, positionClone, rate, floatRate, posiPrincipal, closePrincipal, newClosePercent, annualDays, preEodPosition, eventType, add, swap, orginPv, calcFirst, - calcLast||newCalcLast, consumedInterest)); + calcLast || newCalcLast, consumedInterest)); } } //当日有平仓或互换记录时,避免重复结算 @@ -807,7 +803,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.Info($"[分红-读取] GetPreEodDividendSum tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取EOD日期={(preEod?.ValueDate):yyyy-MM-dd} PosiDividendSum={sum}"); + return sum; } /// @@ -821,6 +819,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.Info($"[分红-快照定位] GetPreEodPositionByDate tradeId={tradeId} positionId={positionId} dealDate={dealDate:yyyy-MM-dd} 取<=当日EOD, 命中日期={(lastEod?.ValueDate):yyyy-MM-dd}, 回退={lastEod == null}"); return QueryPreEodPosition(tradeId, positionId, preEodDate); } @@ -953,6 +952,21 @@ namespace YLErp.Modules.SwapModule /// 本方法内部按保证金维度计算(PreviousBalance),消除原 InitSwapDealInterest 的外部维度 hack /// (融资腿 orginPv=浮动端名义本金)。保留累计语义(priorAccrued + 增量),满足下游字段契约。 /// + /// + /// 前提(由前端保证金表单 + SwapTradeService 构造保证): + /// 1. InterestType=单利。本方法恒走 SimpleInterestAccrual 单利,不查 InterestType; + /// 若库内 InterestMode=5/6 且 InterestType=复利(脏数据),会与旧 CalcEodInterest 复利分支不一致。 + /// 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 一致。 + /// 定位:SwapCalcTrace 落盘 AccrueEod/AccrualPeriod 的 notional/days/rate/accrued;盘中 accrualBasis 可从 trace 的 notional 反推。 + /// /// true=收盘归档(EOD),false=盘中平仓/互换。 /// 互换事件(仅盘中生效,true 时利息归零,同 InitSwapDealInterest)。 public swap_flow_event CalcMarginInterest( @@ -1076,7 +1090,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); } /// @@ -1131,7 +1145,6 @@ namespace YLErp.Modules.SwapModule int annualDays, int eventType, eod_swap_position preEodPosition, - bool needPrice, decimal orginPv, bool calcFirst, bool calcLast, @@ -1206,6 +1219,12 @@ namespace YLErp.Modules.SwapModule consumedInterest, resetCarryInterest); if (preEodPosition.id != 0 && closePrecent == 1m) { + // 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。 + // ⚠️ 契约修复暂缓期间,普通部分平仓经 EOD 恒1惯例【仍会进入本分支】(重算结果已被裁决 + // 证为不落库/不动钱/不进资金,零生产后果);修复落地后部分平仓不再进入——本行日志届时 + // 兼作落地验证哨兵(部分平仓出现在此=修复未生效/被回退)。 + Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " + + $"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}"); // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 // InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。 // 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。 @@ -1435,7 +1454,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; @@ -1549,7 +1568,7 @@ namespace YLErp.Modules.SwapModule { unwindPriceFee = decimal.Parse(unwindPriceFee.ToString("F10")); var td = DbContext.trade.Find(tradeid); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid); + var positions = DbContext.swap_position.ActiveByTrade(td.id); List eventTypes = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换 }; var dealDate = valueDate; var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); @@ -1675,153 +1694,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); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 9bf583aa..f2f06971 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -82,26 +82,42 @@ 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, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, - decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, + decimal posiNotionalValue, 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); + eodPositions, positions, posiNotionalValue, + closePosiNotionalValue, closePrecent, eventType, tdClose, + orginPv, add, settment, newCalcLast, closeList); } + /// + /// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调 + /// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。 + + /// 语义契约见 InterestCalcRequest.EodPostCloseSettle 工厂注释(平仓后剩余 + 实际平掉额 + 恒1全额结息, + /// 触发 GetInterests 内 mode2/mode9 本金修正)。计息走 CalcUnwindInterest 全区间重放。 + /// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。 + /// (契约修复§六暂缓中:落地时 autoSwap=false 分支改 Intraday 形状,见裁决文档与调用点注释。) + /// + protected virtual List CalcEodPostCloseSettleInterests(InterestCalcRequest req) + => CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, + req.PosiNotionalValue, + req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, + req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); + // FindTrade 已上提到基类 SwapTradeBaseService(三子类实现一致,消除重复) /// 查找交易扩展(生产: DbContext.trade_extend;测试: 内存字典) @@ -119,7 +135,7 @@ namespace YLErp.Modules.SwapModule /// 查找交易持仓(生产: DbContext.swap_position.Where;测试: 内存列表) protected virtual List FindSwapPositions(int swapTradeId) { - return DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.Invalid).ToList(); + return DbContext.swap_position.ActiveByTrade(swapTradeId).ToList(); } /// 查找框架合约日终汇总(生产: DbContext.eod_swap.FirstOrDefault;测试: 内存字典) @@ -355,7 +371,7 @@ namespace YLErp.Modules.SwapModule var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); var grossPrice = curEodPosis.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice ?? 0; //处理利息腿 - DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional, posiShortNotional, closePosiNotional, grossPrice, orginPv); + DealInterests(interestList, eodPositions, todyEodPositions, settleDate, td, flowEvents, autoInterests, lastEodSwap, posiLongNotional + posiShortNotional, closePosiNotional, grossPrice, orginPv); //获取自动互换的 interval 信息,用于确定结算日期 IntervalModel autoInterval = null; foreach (var interest in interestList) @@ -427,8 +443,7 @@ namespace YLErp.Modules.SwapModule List flowEvents, List autoInterests, eod_swap lastEodSwap, - decimal posiLongNational, - decimal posiShortNational, + decimal posiTotalNotional, decimal closeNational, decimal grossPrice, decimal orginPv) @@ -439,7 +454,7 @@ namespace YLErp.Modules.SwapModule Log.Info($"[DealInterests] 参数验证 - settleDate: {settleDate:yyyy-MM-dd}, td.id: {td?.id}, td.TradeNumber: {td?.TradeNumber}"); Log.Info($"[DealInterests] 参数验证 - interestList.Count: {interestList?.Count ?? 0}, eodPositions.Count: {eodPositions?.Count ?? 0}, todyEodPositions.Count: {todyEodPositions?.Count ?? 0}"); Log.Info($"[DealInterests] 参数验证 - flowEvents.Count: {flowEvents?.Count ?? 0}, autoInterests.Count: {autoInterests?.Count ?? 0}"); - Log.Info($"[DealInterests] 参数验证 - posiLongNational: {posiLongNational}, posiShortNational: {posiShortNational}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}"); + Log.Info($"[DealInterests] 参数验证 - posiTotalNotional: {posiTotalNotional}, closeNational: {closeNational}, grossPrice: {grossPrice}, orginPv: {orginPv}"); // 验证关键参数 if (td == null) @@ -487,7 +502,7 @@ namespace YLErp.Modules.SwapModule { if (!hasClose)//当日无平仓 { - var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiLongNational, posiShortNational, grossPrice, orginPv); + var _autoInterests = SaveAutoEodInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, lastEodSwap, posiTotalNotional, grossPrice, orginPv); if (_autoInterests.Count > 0) { autoInterests.AddRange(_autoInterests); @@ -495,7 +510,7 @@ namespace YLErp.Modules.SwapModule } else { - var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, true, grossPrice, orginPv); + var _autoInterests = SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, true, grossPrice, orginPv); if (_autoInterests.Count > 0) { autoInterests.AddRange(_autoInterests); @@ -508,11 +523,11 @@ namespace YLErp.Modules.SwapModule } else if (hasClose) { - SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiLongNational, posiShortNational, swapEvents, closeNational, false, grossPrice, orginPv); + SaveAutoEodWithCloseInterestPosition(eodPosition, tdEodPosition, interest, td, settleDate, insterval, posiTotalNotional, swapEvents, closeNational, false, grossPrice, orginPv); } else//无自动互换、互换/平仓,复制上一日终信息,并计算当日新增利息 { - SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiLongNational, posiShortNational, grossPrice, orginPv); + SaveEodInterestPositionCopy(eodPosition, tdEodPosition, settleDate, td, interest, lastEodSwap, true, posiTotalNotional, grossPrice, orginPv); } } } @@ -602,7 +617,7 @@ namespace YLErp.Modules.SwapModule autoInterests.ForEach(x => x.PayDate = settleDate); - var premiumModes = new List() { (int)InterestModeEnum.初始预付金, (int)InterestModeEnum.追加预付金 }; + var premiumModes = MarginModes.ForLinq; var premiumInterests = autoInterests.Where(x => premiumModes.Contains(x.InterestMode)).ToList(); var interestLegs = autoInterests.Where(x => !premiumModes.Contains(x.InterestMode)).ToList(); @@ -1079,7 +1094,7 @@ namespace YLErp.Modules.SwapModule /// 上一平仓/互换日期 /// 当日平仓金额 /// 上一日终框架合约估值 - protected List SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiLongNotional, decimal posiShortNational, decimal grossPrice, decimal orginPv) + protected List SaveAutoEodInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap, decimal posiTotalNotional, decimal grossPrice, decimal orginPv) { Log.Info($"[SaveAutoEodInterestPosition] 开始执行 - valueDate: {valueDate:yyyy-MM-dd}, td.id: {td?.id}, position.id: {position?.id}"); @@ -1118,7 +1133,7 @@ namespace YLErp.Modules.SwapModule } var tradeExtend = td.trade_extend.ExtendObj; - decimal posiNotionalValue = posiLongNotional + posiShortNational; + decimal posiNotionalValue = posiTotalNotional; decimal closePercent = 1; var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); if (eodPayPosition == null) @@ -1138,12 +1153,14 @@ 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)) { 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, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, orginPv, true); decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount); @@ -1228,7 +1245,7 @@ namespace YLErp.Modules.SwapModule /// 当日平仓金额 /// 上一日终框架合约估值 /// 平仓主信息 - protected List SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiLongNotional, decimal posiShortNational, List flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv) + protected List SaveAutoEodWithCloseInterestPosition(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiTotalNotional, List flowEvents, decimal closeNational, bool autoSwap, decimal grossPrice, decimal orginPv) { Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}"); var tradeExtend = td.trade_extend.ExtendObj; @@ -1239,8 +1256,8 @@ namespace YLErp.Modules.SwapModule // 调用共享计息器。因此策略的 "posiNotional × closePercent" 在本例会得到 212197382.46, // 而本次实际应结的平仓本金是 closeNational=90941735.34。该语义错位由 // SwapDealService.GetInterests 的模式2无条件修正、模式9全平零值兜底分流处理,不能删除。 - decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational; - decimal posiNotionalValue = posiLongNotional + posiShortNational; + decimal oriPosiNotionalValue = posiTotalNotional + closeNational; + decimal posiNotionalValue = posiTotalNotional; // ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。 var ratio = DirectionRatio.InterestLegPnl(position.InterestDirection, position.InterestMode); // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 @@ -1276,6 +1293,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)) { @@ -1302,9 +1321,21 @@ 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); + // 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle)。 + // 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 + // ⚠️ 契约修复暂缓中(2026-08-16 撤回):按裁决文档§六,autoSwap=false 本应改传 Intraday 形状 + // (平仓前剩余+真实比例),但该变更影响快照种子(TdInterestPrincipal等),黄金回放验收门未过前不落地; + // 落地时见 项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md §六 与已就绪的回归网 + //(GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口契约口径一致)。 + // 口径选择常驻记录(快速定位第一入口):出问题先看这行确认当日本次事件的金额输入,再顺着 + // SwapCalcTrace 分段过程日志追计算;autoSwap=观察日结现路径。 + Log.Info($"[EOD平仓后收盘结息] tradeId={td.id} valueDate={valueDate:yyyy-MM-dd} autoSwap={autoSwap} " + + $"口径=恒1全额结息(历史行为,契约修复暂缓) " + + $"oriPosi(平仓前)={oriPosiNotionalValue} posi(剩余)={posiNotionalValue} close(平掉)={closeNational}"); + var interests = CalcEodPostCloseSettleInterests(InterestCalcRequest.EodPostCloseSettle( + td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, + posiNotionalValue, closeNational, + eventType, tdClose: false, orginPv, add: true, newCalcLast: autoSwap || calcLast)); // TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。 // interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。 // manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。 @@ -1484,11 +1515,13 @@ namespace YLErp.Modules.SwapModule /// 上一交易日 /// 当前结算日 /// 互换交易主干 - protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiLongNational, decimal posiShortNational, decimal grossPrice, decimal orginPv) + protected void SaveEodInterestPositionCopy(eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, DateTime valueDate, trade td, swap_position position, eod_swap lastEodSwap, bool needPrice, decimal posiTotalNotional, decimal grossPrice, decimal orginPv) { 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) { @@ -1506,7 +1539,7 @@ namespace YLErp.Modules.SwapModule eodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; eodPayPosition.InterestRateDefault = position.InterestRateDefault; eodPayPosition.InterestSwapInterval = position.InterestSwapInterval; - eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiLongNational + posiShortNational; + eodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) ? eodPayPosition.InterestPrincipalFix : posiTotalNotional; eodPayPosition.PosiStartDate = td.StartDate.Value; eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; eodPayPosition.IsAnnualized = position.IsAnnualized; @@ -1529,7 +1562,7 @@ namespace YLErp.Modules.SwapModule orginPv = eodPayPosition.InterestPrincipalFix; } bool longShort = td.StructureType == ClientMarginTypeEnum.多空组合.ToString(); - decimal oriPosiNotionalValue = posiLongNational + posiShortNational; + decimal oriPosiNotionalValue = posiTotalNotional; decimal posiNotionalValue = oriPosiNotionalValue; if (lastEodSwap == null) { @@ -1554,7 +1587,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, posiNotionalValue, closePercent, 0, false, orginPv); UpdateDbOption(newEodPayPosition); newEodPayPosition.PosiStatus = 0; @@ -1740,6 +1773,11 @@ namespace YLErp.Modules.SwapModule curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; + // 分红递推过程常驻记录(快速定位):窗口/数量/税率/当日新计/累计前后值—— + // 配合 BondPaymentService 的[分红-登记日口径]窗口命中日志,构成"命中哪些登记日→算出多少→账滚到多少"全链 + Log.Info($"[分红-EOD计提Copy] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); curretEod.PosiQuantity = eod.PosiQuantity; if (curretEod.PosiStatus == 1) { @@ -1812,11 +1850,11 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); - var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); - var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; - decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); + // 历史遗留死代码已删(2026-08-16,论证+边界测试见 DividendEodNoDoubleCountTest.脏数据边界_*): + // todayConsumedDividend / originNotional / totalPayment / totalInterest 自 0910969e(2026-07-02 + // 改递推式) 起计算结果从未被消费,仅残留一次全历史 CalcBondPayment 只读查询+日志副作用, + // 且构成脏数据(OriginalStockEqvNotional=null/PosiNetPrice=0)下的 EOD 崩溃点。回退=git revert 本提交。 decimal tax = um.ValueAddedTax ?? 0; - decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; @@ -1849,6 +1887,11 @@ namespace YLErp.Modules.SwapModule { curretEod.PosiDividendSum = 0; } + // 分红递推过程常驻记录(快速定位):当日事件路径含实现扣减(前日+新计-当日实现) + Log.Info($"[分红-EOD计提Update] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} TdCloseDividend={curretEod.TdCloseDividend} " + + $"PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.SwapPositionValue -= curretEod.TdCloseDividend; @@ -1871,20 +1914,6 @@ namespace YLErp.Modules.SwapModule return curretEod; } - private decimal CalcConsumedDividend(eod_swap_position curretEod, List events) - { - decimal consumedDividend = 0; - - List swapEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; - //这里要剔除掉平仓产生的分红 - consumedDividend = events - .Where(x => x.SwapTradeId == curretEod.SwapTradeId - && swapEventTypes.Contains(x.EventType) - && x.DataState == (int)SwapFlowDateStateEnum.完成) - .Sum(s => s.DividendIn); - return consumedDividend; - } - /// /// 根据开平仓事件算价格及后付费用 /// @@ -2108,7 +2137,7 @@ namespace YLErp.Modules.SwapModule var tradeSpan = DbContext.trade_span.FirstOrDefault(x => x.TradeId == td.id && x.ValueDate == settleDate); // eod_swap 是交易级汇总;eod_swap_position 是浮动腿、利息腿和保证金腿的明细。 // 以下先按日终明细拆腿,再按框架合约展示口径汇总。 - var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); + var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 // 框架合约的方向约定:多头为正、空头为负;总名义本金取交易原始规模, @@ -2170,7 +2199,7 @@ namespace YLErp.Modules.SwapModule DbContext.eod_swap.Add(eod_Swap); } // 单标的调整与首次归档使用同一套框架合约汇总口径,避免重算后多空和名义本金展示不一致。 - var eodSwapPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == td.id && x.ValueDate == settleDate && !x.Invalid).ToList(); + var eodSwapPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(td.id, settleDate).ToList(); var interestPositions = eodSwapPositions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//利息腿 var positions = eodSwapPositions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode)).ToList();//持仓腿 eod_Swap.NotionalValue = Math.Round(Convert.ToDecimal(td.OriginalStockEqvNotional ?? td.StockEqvNotional), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); @@ -2221,7 +2250,7 @@ namespace YLErp.Modules.SwapModule public SwapLongShortCloseModel GetCloseDetails(int tradeId, DateTime valueDate) { SwapLongShortCloseModel closeModel = new SwapLongShortCloseModel(); - var eodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList(); + var eodPositions = DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList(); var flowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.EventDate == valueDate && x.DataState == (int)SwapFlowDateStateEnum.完成 && x.EventType == (int)SwapEventTypeEnum.平仓 && string.IsNullOrEmpty(x.UnderlyingCode)).ToList(); closeModel.DealPositions = eodPositions.Where(x => x.TdCloseQty != 0).ToList(); closeModel.DealInterests = flowEvents; @@ -2462,7 +2491,7 @@ namespace YLErp.Modules.SwapModule /// public List GetPreEodPositions(int tradeId, DateTime valueDate) { - return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate == valueDate && !x.Invalid).ToList(); + return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).ToList(); } /// /// 获取互换交易日终持仓数据集合 diff --git a/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs b/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs new file mode 100644 index 00000000..7c86a39a --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/SwapPositionQueries.cs @@ -0,0 +1,18 @@ +using System.Linq; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// swap_position 查询收口(Query Object)。 + /// 规则"有效持仓 = SwapTradeId 匹配且未作废(!Invalid)"集中于此, + /// 避免多处复制同一谓词导致语义漂移(漏写 !Invalid 即静默出 bug)。 + /// 仅返回 IQueryable,不调用 SaveChanges,不破坏跟踪/Include/事务边界。 + /// + public static class SwapPositionQueries + { + public static IQueryable ActiveByTrade( + this IQueryable query, int tradeId) + => query.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index ccc58da6..90d67255 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1221,7 +1221,7 @@ namespace YLErp.Modules.SwapModule tradeObj.trade_Initial_Margin = new trade_initial_margin(); } tradeObj.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == intid); - tradeObj.swap_positions = DbContext.swap_position.Where(x => x.SwapTradeId == intid && !x.Invalid).ToList(); + tradeObj.swap_positions = DbContext.swap_position.ActiveByTrade(intid).ToList(); tradeObj.swap_positions = tradeObj.swap_positions.Where(x => x.PosiQuantity > 0 || x.InterestDirection > 0).ToList(); var intervalPositions = tradeObj.swap_positions.Where(x => string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).ToList(); var intervalPositionIds = intervalPositions.Select(s => s.id).ToList(); @@ -1550,7 +1550,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("交易不存在"); } bool backToBegin = td.TradeDate == valueDate; - var swapPositions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid).ToList(); + var swapPositions = DbContext.swap_position.ActiveByTrade(tradeId).ToList(); td.trade_extend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); //展期 diff --git a/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md new file mode 100644 index 00000000..f4836a18 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/TEST-MATRIX.md @@ -0,0 +1,143 @@ +# 融资腿计息测试矩阵 + +> 配套 [ARCHITECTURE.md](ARCHITECTURE.md)。目的:把"覆盖"从用例计数变成格子坐标运算—— +> 每个用例/fix 显式登记命中坐标,空洞一眼可见。2026-08 建立,依据近 6 周 fix 热力图回溯登记。 + +## 0. 范围声明 + +- 本矩阵只覆盖**融资腿 FundingLeg(mode 1 固定值 / 2 合约名义本金规模 / 9 标的期初全价)**。 +- **mode 5/6(保证金/预付金)不属于本矩阵**(历史遗留:被错误建模为计息腿,概念上与融资腿无关, + 见 `Margin/MarginModes.cs` 注释)。保证金有独立的余额模型与专属黄金回放(96 库 60 条,0 差异)作为护栏。 + 禁止向本矩阵添加 5/6 格子。 +- 主力生产组合(确认书规定)**必须全格覆盖**,见 §1。 + +## 1. 主力族(第一优先级,必须全盖) + +``` +InterestMode = 9 标的期初全价 × FR007 浮动(±点差) × InterestType = 复利 × InterestCalcMode = "10"(算头不算尾) +``` + +代码锚点:`SwapDealService.GetInterests`(calcFirst=true / calcLast=false,SwapDealService.cs:646)。 +近 6 周 ≥9 个 fix 落在本族内——生产用得最多 = 人工测试打得最狠,fix 清单就是炸点热力图。 + +## 2. 维度定义 + +| 维度 | 取值 | 代码/数据锚点 | +|---|---|---| +| A 生命周期终点 | 持有至到期结算 / 盘中全平 / 盘中部分后持有 / 部分N次后全平 / EOD自动平仓(部分·全) / 互换(续作) | `SwapEventTypeEnum`;到期:`SwapEodPositionService` 到期结算路径 | +| B 重置几何 | 第1重置期内平仓 / 跨≥1完整重置期 / 第3重置期内 / **平仓日=重置日** / 重置日±1天 / **末段非整周期**(di<7) | 重置频率=7天(已定格,§8);契约重置期定义见 §8a | +| C 比例与次数 | 单次部分(30%) / 同日两次 / 跨重置期多次 / 全平(剩余=0) | `closePrecent`;双语义转换 `ClosePercentMath` | +| D 交收 | T+0 / T+1 | `valueDate` vs `unwindDate` | +| E FR007 形态 | 每重置日有价 / 加点(+0.25%) / 减点(-2.10%) / **取价日=重置日上一营业日**(契约规定) / 缺价分支 | `TryGetFloatRate` / `ResolveFloatRate`;66a97e03 对应此维 | +| F 入口 | 见 §3 | | +| G 断言投影 | ①最终利息金额 ②`TdInterestPrincipal` 逐日携带链 ③`InterestIncomeSum`+flow_event 全字段 ④方向/符号(报表口径) | 每格必须断言全部 4 个投影 | + +## 3. 入口枚举(F 维) + +| 入口 | 代码路径 | +|---|---| +| 盘中平仓/互换结息试算 | `SwapDealService.GetInterestsForUnwind`(SwapDealService.cs:617,settment:false → `CalcUnwindInterest`) | +| EOD 正常收盘 | `GetInterests(settment:true)` → `CalcEodInterest` | +| EOD 平仓后收盘 | `SwapEodPositionService.SaveAutoEodWithCloseInterestPosition`(:1246)→ `CalcSwapInterests`(:1579) | +| EOD 自动互换 | `CalcSwapInterests`(:1161,EventType=自动互换) | + +已知风险:`GetInterests` 参数语义随入口漂移(EOD 平仓后收盘传"剩余本金+percent=1", +盘中传"平仓前本金+实际比例"),`GetInterestsEntrySemanticsTest` 曾实测双入口复利口径分歧(b01b485e)。 + +## 4. fix 热力图(本族,近 6 周) + +| fix | 落点 | 格子坐标 | 自带测试 | +|---|---|---|---| +| 66a97e03 重置日=平仓日 calcLast 不跳过 FR007 取价 | SwapDealService:1249/1295 | B=重置日=平仓日 × E=取价边界 | GLMS20260805FR007UnderlyingIdDiagnoseTest(581行) | +| 48e84479 重置日部分平仓本金 | SwapEodPositionService:1422 | B=重置日=平仓日 × C=部分 | SwapCloseConversationCasesRegressionTest(358行) | +| d3afa6d2 T+1 部分平仓复利本金(算头不算尾快速路径) | SwapDealService:1243 | D=T+1 × C=部分 × A=部分后持有 | DealInterestsScenarioTest +36行 | +| aa5a5ed8 算头不算尾期初复利部分平仓 | SwapEodPositionService:1418-1573 | **本族正中心** | DealInterestsScenarioTest | +| a0be0eb0 复利平仓已结利息扣除 | SwapDealService:1294 | 已结利息差分(CalcDailyCompoundInterest 回放) | ConsumedInterestScenarioTest | +| 5539bd9c 复利部分平仓后 EOD 本金 | SwapEodPositionService:782/1381 | G=携带链投影 | DealInterestsScenarioTest +34行 | +| feffc196 Bug A/B/C 浮动部分/全平尾差 | SwapDealService:1214/1292 | C=部分/全平 × E=浮动 | **SwapInterestScenario3And4FloatingTest(24用例,Excel oracle)** | +| 2035e1df EOD 平仓后收盘结息本金语义 | SwapDealService:1293 / EodService:860,1360 | **F=EOD平仓后收盘 × C** | **无测试** | +| b01b485e 双入口口径分歧(实测发现) | — | F=入口 × 全族 | GetInterestsEntrySemanticsTest(字符化,非 oracle) | + +**规律:fix 全部落在 `CalcUnwindInterest`(SwapDealService 1240-1300)和 +`SaveAutoEodWithCloseInterestPosition` 族(SwapEodPositionService 1380-1580)两个带。** + +## 5. 现有用例登记 + +| 测试文件 | 覆盖格子 | oracle 类型 | +|---|---|---| +| SwapInterestScenario3And4FloatingTest(24) | 本族 A=全平/部分30%→全平 × B=第3重置期内 × D=T+0/T+1 × E=加减点 × F=EOD平仓后收盘 × G=仅金额投影 | Excel 手算(业务源) | +| SwapInterestScenario1And2Test(32) | A=收盘平仓 × B=第1重置期内 × E=固定/浮动 | Excel 手算 | +| DealInterestsScenarioTest(24 方法,工单逐个追加) | 部分平仓×复利族各点,含"10"×3 行 | 工单期望值 | +| ConsumedInterestScenarioTest | 已结利息差分族 | 工单期望值 | +| SwapUnwindSameDayDoublePartialTest | C=同日两次 | **字符化(非独立 oracle)** | +| GetInterestsEntrySemanticsTest | F=双入口一致性 | **字符化** | +| **ContractReferenceOracleTest(Accrual/,7)** | mode9/mode2 × 复利 × "10" × T+0 × 部分30% × B=跨12整期+末段(90/89天) × E=恒定利率(取价日免疫) | **契约公式参考实现(§7.4 第一级)**——引擎盘中重放已逐分对齐 oracle | +| GetInterestsUnitTest_T0/T1(89) | mode 1 固定值 T+0/T+1 族(非本族) | 单点断言 | +| GLMS20260805FR007UnderlyingIdDiagnoseTest | B=重置日=平仓日 × E | 诊断+断言 | + +## 6. 空洞清单(热力图 ∩ 未覆盖,按优先级) + +1. **F=EOD平仓后收盘 × C=部分平仓 × 本族** —— 2035e1df 无测试落地即合入,该入口×比例格子全裸。 +2. **G=携带链投影(全族)** —— 现有断言几乎全是最终金额;`TdInterestPrincipal` 逐日携带链无一处断言 + (7528670e 在单利上炸过同款,复利同投影裸奔)。 +3. **B=重置日±1天 / 跨重置期多次部分平仓** —— 热力图边缘未扫。 +4. **A=到期结算 × 本族** —— db46e48e 修过到期结算(28 断言),但非本族参数。 +5. **A=互换(续作) × 本族** —— 7411b9d2/421662a0 炸过续作初始化,本族续作无 oracle。 +6. **C=同日两次** —— 只有字符化测试,无独立 oracle(字符化=锁定现状,不证正确)。 +7. **E=缺价/取价日边界** —— 66a97e03 只修了取价跳过,缺价分支行为未钉。 + +## 7. 补盖执行顺序 + +1. 先铺**守恒不变量**(免 oracle,全格便宜):部分平仓后"期初=平掉+剩余"逐日守恒;全平后持仓=0; + 复利重置日动态本金=前段本金+利息;多次平仓 closePercent 连乘=累计比例。 +2. 空洞 1/2 优先:按 §2-G 四投影补 EOD平仓后收盘 × 部分 用例,oracle 用确认书公式 Excel 模板。 +3. 空洞 6 补独立 oracle(确认书公式),替换字符化地位(保留字符化作回归钉)。 +4. 每格期望值来源分级(已升级,见 §8a):**契约公式独立参考实现** > 生产已对账数字 > 业务签认 Excel > 新旧影子对比;**禁止当前代码输出充当 oracle**。 +5. 契约参考实现(§8a 公式)**已落地**(`UnitTestProject/Modules/SwapModule/Accrual/ContractReferenceCalc.cs`, + 独立于生产引擎,禁止引用计息类防同源),引擎对照首批 3 例全绿(mode9/mode2 × "10" × 部分30%, + `ContractReferenceOracleTest`)。后续补格直接复用:期望值 = `ClosedInterest(平掉额, ReferenceRateAbsolute(...))`。 + 待办:变利率引擎侧对照(取价日 E 维)、确认书生成器参数同源断言(`swap_position`)。 + +## 8. 生产参数(已确认,2026-08) + +- **重置频率 = 7 天**(确认书:"重置频率每【周】";完整重置期 di=7 天) +- **年化基数 = 365**(确认书:"计息基准 A/365",固定利率公式同除 365) +- 生产只有这一种组合,无 360/其他重置频率。现有测试参数 `ResetPeriod=7 / AnnualDays=365` **即为生产主力参数,格子按此定格**。 + +## 8a. 契约 oracle(确认书公式原文) + +模板:`Plugins/YLErp.Plugins.GuoLian/App_Docs/contract_template/*.docx`(看多/看空 × 现券/债券ETF 共 4 份,计息条款一致); +变量替换:`Plugins/YLErp.Plugins.GuoLian/DocumentGenerator/TradeConfirmationGenerator.cs` +(`重置频率=interest_rest_days天`、`利差=InterestRateDefault×10000bp`,与计息引擎同源 `swap_position` 字段)。 + +**浮动利率复利公式(本族契约正文)**: + +``` +参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 +``` + +- k = 计息期包含的重置期个数;di = 第 i 个基准利率适用的日历日数,**完整重置期 di=7,末段不足 7 按实际日历日**(测试必须盖非整周期:如持有 17 天 = 2×7+3) +- **利率确定日 = 每个重置期首日(重置日)的上一个营业日**,取该日 FR007;营业日准则=上一营业日(→ E 维度取值锚点,与 66a97e03 修复直接对应) +- FR007 取中国货币网每日公布值 + +**计息期定义(= 算头不算尾的契约原文)**:自起始日(含)至到期日(不含)的自然日天数。 +⚠ 债券ETF 模板变体:计息期自**期初观察日**(含)至**期末观察日**(不含)——观察日→代码日期字段的映射需单独核实,是一个潜在口径分叉点。 + +**重置期定义**:每个重置期自上一重置日(含)至下一重置日(不含);首个重置期始于计息期首日;最后一个重置期的最后一日为计息期最后一日(末段收口)。重置日从计息期首日按重置频率依次推算。 + +**固定利率公式**:参考利率(绝对) = 固定利率 × 计息期 / 365。 +**期初预付金利息**:支付日(含)至到期日(**含**)×利率/计息基准——注意预付金契约上是"含尾"的,与利率腿"不含尾"相反。 + +**oracle 使用方式(升级 §7)**:最强形式是**按契约公式写独立参考实现**(约 20 行:重置日推算 + 分段取价 + ∏ 公式 + 末段收口),作为测试 oracle 与生产引擎对照,容差 0.01。它比逐格 Excel 手算更便宜且零同源风险;Excel 模板退化为抽样校验参考实现本身。 + +## 9. 合入规则(硬约束) + +1. 计息类 fix:**先失败测试,后修代码**;测试须登记本矩阵坐标。 +2. 修一格必须**扫同矩阵行兄弟格子**(同 fix 家族的邻格)。 +3. 任何触碰 `GetInterests`/`CalcUnwindInterest`/`SaveAutoEodWithCloseInterestPosition` 的 PR: + 跑 `DealInterestsGoldenReplayTest` 全量 + 保证金黄金回放(防共享管线殃及)。 +4. 登记 fix 时发现同格已有用例而 bug 仍发生 → 先修断言投影,再修代码。 +5. **oracle 用例与裁决材料一律取 §8 生产参数**(7 天重置 / A365 / 真实点差 ±0.25%·−2.10% / + 千万级名义本金,如 5000 万)。玩具参数(千元级/重置 3 天/点差 1%)仅限字符化钉子测试—— + 其用途是锁行为防漂移,不承担"证明数字正确"职责;用玩具数字做裁决依据会掩盖金额量级 + (0.03 vs 0.06 看着"不大",同参数放大到生产即 7.6 万 vs 25 万/笔)。 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/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 7242618e..ce9a9714 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -28,7 +28,8 @@ namespace YLErp.Modules.TradeModule.DealModule public List Execute(DateTime settleDate, IEnumerable positions) { var result = new List(); - var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V); + var dict = GetExDividendQuery(settleDate) + .ToDictionary(K => K.UnderlyingId, V => V); foreach (var item in positions) { double cost = item.Cost, @@ -76,7 +77,8 @@ namespace YLErp.Modules.TradeModule.DealModule useSaveTrades = new List(); useSaveUndedrlyings = new List(); var result = new List(); - var dict = DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == settleDate).ToDictionary(K => K.UnderlyingId, V => V); + var dict = GetExDividendQuery(settleDate) + .ToDictionary(K => K.UnderlyingId, V => V); var tradeIds = trades.Select(O => O.id); var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); foreach (var t in trades) @@ -713,9 +715,15 @@ namespace YLErp.Modules.TradeModule.DealModule { return 0; } - var ratio = overrideDividendRatio != null ? overrideDividendRatio.Value : GetRatio(info); - double? result = price / ratio; - return Math.Round(result ?? 0, 4, MidpointRounding.AwayFromZero); + var decimalRatio = overrideDividendRatio.HasValue + ? (decimal)overrideDividendRatio.Value + : GetRatioDecimal(info); + if (decimalRatio == 0) + { + return 0; + } + var result = (decimal)price / decimalRatio; + return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero); } /// @@ -725,10 +733,17 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetRatio(ex_dividend_info info) { - var dividendRate = valuedateBLL.SystemDate.DividendRate / 100; + return (double)GetRatioDecimal(info); + } + + private decimal GetRatioDecimal(ex_dividend_info info) + { + var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m; var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice); - var cDivdPrice = (closePrice * 10.0 - (info.GiveCashAmount * (1 - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / (10 + info.GiveShareAmount + info.RationedSharesAmount); - return closePrice / cDivdPrice; + var decimalClosePrice = (decimal)closePrice; + var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / + (10m + info.GiveShareAmount + info.RationedSharesAmount); + return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice; } /// @@ -751,18 +766,20 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { - double? result = amount * (1 + info.GiveShareAmount / 10.0); - return Math.Round(result ?? 0, 12); + var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); + return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } public IQueryable GetExDividendQuery(DateTime valueDate) { - return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate == valueDate); + return DbContext.ex_dividend_info + .Where(O => O.ValidStatus && O.ExDividendDate == valueDate); } public IQueryable GetExDividendQuery(DateTime dateStart, DateTime dateEnd) { - return DbContext.ex_dividend_info.Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd); + return DbContext.ex_dividend_info + .Where(O => O.ValidStatus && O.ExDividendDate >= dateStart && O.ExDividendDate <= dateEnd); } public IEnumerable GetExDividends(DateTime valueDate, params int[] underlyingIds) @@ -772,7 +789,7 @@ namespace YLErp.Modules.TradeModule.DealModule { query = query.Where(n => underlyingIds.Contains(n.UnderlyingId)); } - return query.ToArray(); + return query; } public IQueryable GetExDividendInfos(string underlyingCode) @@ -797,17 +814,17 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException("请使用正确的模板上传"); } - var dict = new Dictionary(); + var dividendInfos = new List(); for (var i = 0; i < dt.Rows.Count; i++) { var info = new ex_dividend_info { UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(), ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue, - GiveCashAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, - GiveShareAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, - RationedSharesAmount = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, - RationedSharesPrice = double.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, + GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, + GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, + RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, + RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, OptId = OptUser.UserId, OptName = OptUser.UserName, OptDate = DateTime.Now @@ -824,9 +841,9 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行股权登记日不正确"); } - dict[$"{info.ExDividendDate}{info.UnderlyingCode}"] = info; + dividendInfos.Add(info); } - if (!AddDividendInfos(dict.Values, out var errMsg)) + if (!AddDividendInfos(dividendInfos, out var errMsg)) { throw new ServiceException(errMsg); } @@ -841,48 +858,163 @@ namespace YLErp.Modules.TradeModule.DealModule return ""; } + private ex_dividend_info FindExDividendByBusinessKey(int underlyingId, DateTime exDividendDate, int excludedId = 0) + { + // 业务唯一键按“标的 + 自然日”定义,而不是按完整 DateTime 定义。 + // 因此这里使用 [当天 00:00, 次日 00:00) 查询,兼容历史数据中可能存在的时分秒。 + // excludedId 用于编辑已有记录时排除自身,避免把当前记录误判为重复记录。 + return DbContext.ex_dividend_info.FirstOrDefault(O => O.UnderlyingId == underlyingId + && O.ExDividendDate >= exDividendDate + && O.ExDividendDate < exDividendDate.AddDays(1) + && (excludedId <= 0 || O.id != excludedId)); + } + + private static void MergeNonZeroDividendValues(ex_dividend_info target, ex_dividend_info source) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + // 同一业务键可能分别来自多行导入,或来自“数据库旧记录 + 当前导入记录”。 + // 每个字段独立合并:当前值非零时覆盖旧值,当前值为零时保留旧值, + // 这样派息、送股、配股数量、配股价格可以从不同来源补齐到同一行。 + // 该约定将零解释为“未提供”,因此不能通过普通导入把已有字段显式清零。 + if (source.GiveCashAmount != 0m) + { + target.GiveCashAmount = source.GiveCashAmount; + } + if (source.GiveShareAmount != 0m) + { + target.GiveShareAmount = source.GiveShareAmount; + } + if (source.RationedSharesAmount != 0m) + { + target.RationedSharesAmount = source.RationedSharesAmount; + } + if (source.RationedSharesPrice != 0m) + { + target.RationedSharesPrice = source.RationedSharesPrice; + } + } + public bool AddDividendInfos(IEnumerable infos, out string errMsg) { try { - var keys = infos.Select(O => $"{O.ExDividendDate?.ToString("yyyy-MM-dd")}{O.UnderlyingCode}"); - var ids = infos.Select(O => O.id).ToHashSet(); - - var data = from dividendDb in DbContext.ex_dividend_info.Where(O => keys.Contains(O.ExDividendDate + O.UnderlyingCode) && O.ValidStatus) - where !ids.Contains(dividendDb.id) - select dividendDb; - if (data.Any()) + var dividendInfos = infos?.ToList(); + if (dividendInfos == null || dividendInfos.Count == 0) { - var dd = data.Select(O => O.UnderlyingCode + "_" + O.ExDividendDate).ToArray(); - errMsg = string.Join(",", dd) + "已存在除息信息,请修改原数据"; + errMsg = "没有可保存的除权除息信息"; return false; } - var basketList = - DataCacheProvider.GetUnderlyingDataSource() - .AsQueryable().Where(O => O.IsBasket() && O.SubData != null) - .Select(O => new { O.UnderlyingCode, O.SubData }); - IEnumerable priceList = null; - foreach (var item in infos) + var preparedInfos = new List<(ex_dividend_info Item, underlying_manager Underlying, DateTime ExDividendDate)>(); + var preparedIndexes = new Dictionary<(int UnderlyingId, DateTime ExDividendDate), int>(); + var recordKeys = new Dictionary(); + foreach (var item in dividendInfos) { + if (item == null || string.IsNullOrWhiteSpace(item.UnderlyingCode)) + { + errMsg = "标的代码信息不存在"; + return false; + } + var underlying = underlying_managerBLL.GetByCode(item.UnderlyingCode); if (underlying == null) { errMsg = $"{item.UnderlyingCode} 标的信息不存在"; return false; } + if (!item.ExDividendDate.HasValue) + { + errMsg = "股权登记日信息不存在"; + return false; + } + + // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 + // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 + var exDividendDate = item.ExDividendDate.Value.Date; + var businessKey = (underlying.id, exDividendDate); + if (item.id > 0 + && recordKeys.TryGetValue(item.id, out var existingRecordKey) + && existingRecordKey != businessKey) + { + errMsg = "同一除权信息不能重复保存"; + return false; + } + item.UnderlyingId = underlying.id; - item.GiveCashAmount = item.GiveCashAmount.FormatValue(6); - item.RationedSharesAmount = item.RationedSharesAmount.FormatValue(6); - item.RationedSharesPrice = item.RationedSharesPrice.FormatValue(6); - item.GiveShareAmount = item.GiveShareAmount.FormatValue(6); - item.ValidStatus = true; - item.OptId = OptUser.UserId; - item.OptName = OptUser.UserName; - item.OptDate = DateTime.Now; - var dividend = item.id > 0 ? DbContext.ex_dividend_info.Where(O => O.id == item.id).FirstOrDefault() : null; + item.ExDividendDate = exDividendDate; + item.GiveCashAmount = OtcFormatHelper.FormatValue(item.GiveCashAmount, 6); + item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6); + item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6); + item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6); + + // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录 + // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。 + if (preparedIndexes.TryGetValue(businessKey, out var preparedIndex)) + { + var preparedItem = preparedInfos[preparedIndex].Item; + // 同一业务键下允许重复的是同一条记录(两个新对象都为 id=0, + // 或两个对象的 id 相同);不同 id 代表不同存量记录,不能静默合并。 + if ((preparedItem.id == 0) != (item.id == 0) + || preparedItem.id > 0 && item.id > 0 && preparedItem.id != item.id) + { + errMsg = $"{item.UnderlyingCode} {exDividendDate:yyyy-MM-dd}除权信息不能合并不同记录"; + return false; + } + + MergeNonZeroDividendValues(preparedItem, item); + if (item.id > 0) + { + recordKeys[item.id] = businessKey; + } + continue; + } + + if (item.id > 0) + { + recordKeys[item.id] = businessKey; + } + preparedIndexes.Add(businessKey, preparedInfos.Count); + preparedInfos.Add((item, underlying, exDividendDate)); + } + + var basketList = + DataCacheProvider.GetUnderlyingDataSource() + .AsQueryable().Where(O => O.CommodityCode == "篮子标的" && O.SubData != null) + .Select(O => new { O.UnderlyingCode, O.SubData }); + IEnumerable priceList = null; + foreach (var prepared in preparedInfos) + { + var item = prepared.Item; + var underlying = prepared.Underlying; + var itemDate = prepared.ExDividendDate; + // id>0 表示前端正在编辑指定的存量记录;id=0 时先按自然日业务键 + // 查找数据库旧记录,使“新增导入”也能与已有记录合并,而不是重复插入。 + var dividend = item.id > 0 + ? DbContext.ex_dividend_info.FirstOrDefault(O => O.id == item.id) + : FindExDividendByBusinessKey(underlying.id, itemDate); if (dividend == null) - { DbContext.ex_dividend_info.Add(item); } + { + if (item.id > 0) + { + errMsg = "未找到要修改的除权除息信息"; + return false; + } + item.DataSource = ExDividendDataSources.Manual; + item.SourceUpdatedAt = null; + item.ValidStatus = true; + item.OptId = OptUser.UserId; + item.OptName = OptUser.UserName; + item.OptDate = DateTime.Now; + DbContext.ex_dividend_info.Add(item); + } else { if (checkDividendInfoExecuteStatus(dividend)) @@ -890,28 +1022,37 @@ namespace YLErp.Modules.TradeModule.DealModule errMsg = $"{dividend.UnderlyingCode} {dividend.ExDividendDate?.ToString("yyyy-MM-dd")}除权信息保存失败,该信息已被执行,不允许修改!"; return false; } + var conflictingDividend = FindExDividendByBusinessKey(underlying.id, itemDate, dividend.id); + if (conflictingDividend != null) + { + errMsg = $"{item.UnderlyingCode} {itemDate:yyyy-MM-dd}除权信息已存在,不能修改为该业务键"; + return false; + } + var sourceUpdatedAt = dividend.SourceUpdatedAt; dividend.UnderlyingCode = item.UnderlyingCode; dividend.UnderlyingId = item.UnderlyingId; dividend.ExDividendDate = item.ExDividendDate; - dividend.GiveCashAmount = item.GiveCashAmount; - dividend.RationedSharesAmount = item.RationedSharesAmount; - dividend.RationedSharesPrice = item.RationedSharesPrice; - dividend.GiveShareAmount = item.GiveShareAmount; - dividend.ValidStatus = item.ValidStatus; - dividend.OptId = item.OptId; - dividend.OptName = item.OptName; - dividend.OptDate = item.OptDate; + // 数据库已有记录也必须走与批次内重复行相同的合并规则:导入字段非零 + // 才覆盖旧值,导入字段为零则保留数据库存量值,避免一次不完整导入 + // 把旧的派息/送股/配股信息误清零。 + MergeNonZeroDividendValues(dividend, item); + dividend.ValidStatus = true; + dividend.DataSource = ExDividendDataSources.Manual; + dividend.SourceUpdatedAt = sourceUpdatedAt; + dividend.OptId = OptUser.UserId; + dividend.OptName = OptUser.UserName; + dividend.OptDate = DateTime.Now; } if (!basketList.Any()) { continue; } - var codes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode); - if (!codes.Any()) + var basketCodes = basketList.Where(O => O.SubData.Contains(item.UnderlyingCode)).Select(O => O.UnderlyingCode); + if (!basketCodes.Any()) { continue; } - var removePriceList = DbContext.eod_stock_price.Where(O => codes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate); + var removePriceList = DbContext.eod_stock_price.Where(O => basketCodes.Contains(O.UnderlyingCode) && O.ValueDate > item.ExDividendDate); if (!removePriceList.Any()) { continue; @@ -954,7 +1095,7 @@ namespace YLErp.Modules.TradeModule.DealModule return true; } //查询篮子标的对应交易是否执行过收盘操作; - var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.IsBasket() && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray(); + var umList = DataCacheProvider.GetUnderlyingDataSource().AsQueryable(O => O.CommodityCode == "篮子标的" && O.SubData != null && O.SubData.Contains(info.UnderlyingCode)).Select(O => O.UnderlyingCode).ToArray(); tradeQuery = from t in DbContext.trade.Where(O => umList.Contains(O.UnderlyingCode) && O.TradeDate <= info.ExDividendDate && O.ExerciseDate >= info.ExDividendDate && O.DividendDate >= O.TradeDate) join et in DbContext.eod_trade.Where(O => ConsTrade.LiveTradeStatusList.Contains(O.TradeStatus)) on new { t.id, ValueDate = t.TradeDate.Value } equals new { id = et.TradeId, et.ValueDate } diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs index 2ac4c1ac..319889ac 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/ConfirmationGenerateContext.cs @@ -34,6 +34,7 @@ using YLErp.Office.Converters; using YLErp.Plugins.TradeDocGenerator; using YLErp.Plugins.TradeDocGenerator.Abstracts; using YLErp.QdpModule; +using YLErp.Modules.SwapModule; namespace YLErp.Modules.TradeModule.DocGenerateModule { @@ -2864,7 +2865,7 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule } public List GetEodPositions(int tradeId, DateTime valueDate) { - return DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid && x.ValueDate == valueDate).AsNoTracking().ToList(); + return DbContext.eod_swap_position.ActiveByTradeAndDate(tradeId, valueDate).AsNoTracking().ToList(); } public List GetSwapFlowDeals(int tradeId) diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index 9cfc6b58..0b194a6d 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -130,8 +130,11 @@ + + + diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index d64816df..ce9c2139 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"} @@ -111,4 +112,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/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/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/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 @@ - + - + +} +
+ + + @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/pricing/structure.js b/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js index 5652bb7f..efb5a424 100644 --- a/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js +++ b/YLErpWeb/wwwroot/Scripts/app/pricing/structure.js @@ -109,9 +109,52 @@ const vueTradeType = function () { }; }; -//标的选择组件 +//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死) const vueUnderlying = function () { const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html()); + // 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突) + const _cache = {}; + const _tokens = {}; + function _fetch(varietyId, query, cb) { + var key = (varietyId || 0) + '|' + (query || ''); + var token = (_tokens[key] = (_tokens[key] || 0) + 1); + var postData = { + FilterCode: (query || '').toUpperCase(), + VarietyId: varietyId || 0, + MaxShowLength: 20, + BlackLimit: 1, + UseForTrading: true, + IncludeMatured: true, + CheckLaunch: true + }; + main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) { + if (_tokens[key] !== token) return; // 丢弃过期响应 + var arr = (res && (res.obj || res.data)) || []; + var norm = arr.map(function (x) { + return { + Code: x.Code, + Name: x.Name, + InstrumentType: x.InstrumentType, + VarietyId: x.VarietyId, + Disallow: !!x.Disallow, + IsCombined: !!x.IsSynthetic || !!x.IsBasket, + BlackWhiteState: x.BlackWhiteState || 0, + PinYin: x.PinYin || '' + }; + }); + cb && cb(norm); + }); + } + function _filter(list, query, varietyId) { + if (!query) return (list || []).slice(0, 20); + query = query.toUpperCase(); + return (list || []).filter(function (x) { + if (varietyId && x.VarietyId !== varietyId) return false; + if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的 + return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1) + || (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1); + }).slice(0, 20); + } return { props: ['underlying'], data() { @@ -120,27 +163,24 @@ const vueUnderlying = function () { mounted() { var self = this; this.jqInput = $(this.$el).children(0); + // EQD-7049:预拉默认20条(当前品种),避免下拉空白 + _fetch(self.underlying.VarietyId, '', function (list) { + _cache[self.underlying.VarietyId || 0] = list; + try { $(self.jqInput).autocomplete('search', ''); } catch (e) {} + }); this.autoctrl = FastVue.autocomplete(this.jqInput, { valueField: 'Code', lookup(query, callback) { - var arr = []; - if (!query) { - var varietyId = self.underlying.VarietyId; - ylotc.underlyings.forEach(x => { - (!varietyId || x.VarietyId === varietyId) && arr.push(x); - }); - } else { - query = query.toUpperCase(); - ylotc.underlyings.forEach(x => { - if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) { - arr.push(x); - } + var varietyId = self.underlying.VarietyId; + var cached = _cache[varietyId || 0] || []; + var immediate = _filter(cached, query, varietyId); + if (query) { + // 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃) + _fetch(varietyId, query, function (list) { + _cache[varietyId || 0] = list; }); } - if (arr.length < 30) { - arr = _.sortBy(arr, x => x.Code); - } - return arr; + return immediate; }, onSelect(data) { if (self.underlying !== data) { @@ -1430,6 +1470,12 @@ const vueTrade = function () { //更新标的 updateUnderlying(reqData, fromSelect) { let self = this; + // EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失” + var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0); + if (!hasQueryKey) { + !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); + return; + } var instTypeChanged = !!reqData.InstrumentType; !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) { diff --git a/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js b/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js index 4cf2f0fc..e5636152 100644 --- a/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js +++ b/YLErpWeb/wwwroot/Scripts/app/pricing/structure_dz.js @@ -108,9 +108,52 @@ const vueTradeType = function () { }; }; -//标的选择组件 +//标的选择组件(EQD-7049:改为服务端搜索,不再依赖全量 ylotc.underlyings,避免十几万标的整段下载卡死) const vueUnderlying = function () { const _suggestionTpl = _.template($('#underlyingSuggestionTpl').html()); + // 标的缓存:按 品种|关键词 隔离;乱序响应由 token 丢弃(helper 收在函数内,避免全局绑定冲突) + const _cache = {}; + const _tokens = {}; + function _fetch(varietyId, query, cb) { + var key = (varietyId || 0) + '|' + (query || ''); + var token = (_tokens[key] = (_tokens[key] || 0) + 1); + var postData = { + FilterCode: (query || '').toUpperCase(), + VarietyId: varietyId || 0, + MaxShowLength: 20, + BlackLimit: 1, + UseForTrading: true, + IncludeMatured: true, + CheckLaunch: true + }; + main.post('/frontdata/AjaxGetUnderlyingSelect', postData).done(function (res) { + if (_tokens[key] !== token) return; // 丢弃过期响应 + var arr = (res && (res.obj || res.data)) || []; + var norm = arr.map(function (x) { + return { + Code: x.Code, + Name: x.Name, + InstrumentType: x.InstrumentType, + VarietyId: x.VarietyId, + Disallow: !!x.Disallow, + IsCombined: !!x.IsSynthetic || !!x.IsBasket, + BlackWhiteState: x.BlackWhiteState || 0, + PinYin: x.PinYin || '' + }; + }); + cb && cb(norm); + }); + } + function _filter(list, query, varietyId) { + if (!query) return (list || []).slice(0, 20); + query = query.toUpperCase(); + return (list || []).filter(function (x) { + if (varietyId && x.VarietyId !== varietyId) return false; + if (x.IsCombined) return false; // 与原逻辑一致:搜索时排除组合标的 + return (x.Code && x.Code.toUpperCase().indexOf(query) !== -1) + || (x.PinYin && x.PinYin.toUpperCase().indexOf(query) !== -1); + }).slice(0, 20); + } return { props: ['underlying'], data() { @@ -119,27 +162,24 @@ const vueUnderlying = function () { mounted() { var self = this; this.jqInput = $(this.$el).children(0); + // EQD-7049:预拉默认20条(当前品种),避免下拉空白 + _fetch(self.underlying.VarietyId, '', function (list) { + _cache[self.underlying.VarietyId || 0] = list; + try { $(self.jqInput).autocomplete('search', ''); } catch (e) {} + }); this.autoctrl = FastVue.autocomplete(this.jqInput, { valueField: 'Code', lookup(query, callback) { - var arr = []; - if (!query) { - var varietyId = self.underlying.VarietyId; - ylotc.underlyings.forEach(x => { - (!varietyId || x.VarietyId === varietyId) && arr.push(x); - }); - } else { - query = query.toUpperCase(); - ylotc.underlyings.forEach(x => { - if (x.Code.toUpperCase().indexOf(query) !== -1 || x.PinYin && x.PinYin.indexOf(query) !== -1 && !x.IsCombined) { - arr.push(x); - } + var varietyId = self.underlying.VarietyId; + var cached = _cache[varietyId || 0] || []; + var immediate = _filter(cached, query, varietyId); + if (query) { + // 有输入时异步向服务端搜索并刷新缓存(乱序响应由 token 丢弃) + _fetch(varietyId, query, function (list) { + _cache[varietyId || 0] = list; }); } - if (arr.length < 30) { - arr = _.sortBy(arr, x => x.Code); - } - return arr; + return immediate; }, onSelect(data) { if (self.underlying !== data) { @@ -1044,6 +1084,12 @@ const vueTrade = function () { //更新标的 updateUnderlying(reqData, fromSelect) { let self = this; + // EQD-7049:新建空白页未选标的/品种/类型时,跳过必然失败的后端默认标的查询,避免报“标的信息缺失” + var hasQueryKey = !!(reqData.UnderlyingCode || reqData.InstrumentType || reqData.VarietyId > 0); + if (!hasQueryKey) { + !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); + return; + } var instTypeChanged = !!reqData.InstrumentType; !fromSelect && (self.viewState.underlying = tradeHelper.getEmptyUnderlying()); main.post("/pricing/AjaxGetUnderlying", reqData).done(function (resp) { 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) { 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; } 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 调用 | diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..604ef056 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md b/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md new file mode 100644 index 00000000..28fc2867 --- /dev/null +++ b/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md @@ -0,0 +1,120 @@ +# 裁决申请:复利×mode2×部分平仓 双入口结息额分歧 + +> 2026-08-16 | 来源:`GetInterestsEntrySemanticsTest`(b01b485e 实测钉住) +> 状态:**§六修复暂缓(2026-08-16 撤回,待黄金回放验收门)**——口径依据=确认书公式(契约唯一确定,无可裁决项); +> 修复影响快照种子(TdInterestPrincipal/TdInterestIncome 等下游分支),须全量基线+黄金回放通过后落地, +> 而 96 库网络未恢复导致验收门无法执行。落地材料已全部就绪:方案(本文件§六)+回归网 +> (GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口契约口径一致——同请求形状等价 oracle+手算锚点) +> +观察日保持钉子+[EOD平仓后收盘结息]/[利息-全平专属分支]常驻日志(落地验证哨兵)。 +> 特判退役(mode2覆盖/mode9兜底)**另案暂缓**:copy 路径(部分平仓后日结转)依赖特判维持 closePrincipal=剩余全额, +> 其 closePercent=剩余比例语义需先理清,留作独立重构。 + +## 一、分歧事实(生产代码实测,已钉住) + +### 1.1 真实规模复算(确认书生产参数,裁决请以此为准) + +参数全部取自确认书规定与生产实配(见 TEST-MATRIX §8/§8a): +名义本金 **5000 万**;FR007=1.8% + 点差 **+0.25%** = all-in 2.05%;重置频率 **7 天**; +计息基准 **A/365**;持有 **90 天**(12×7 天完整重置期 + 6 天末段);**部分平仓 30%**(平掉 1500 万,剩余 3500 万)。 + +| 口径 | 结息额 | 计算方式 | +|---|---:|---| +| 契约公式(应结) | **75,999.04 元** | 平掉额 1500万 × 参考利率(90天)= ∏公式,手算见 §二 | +| 盘中平仓试算 | **75,999.04 元** | 与契约公式一致(引擎实测,容差内) | +| EOD 平仓后收盘(现结) | **251,355.08 元** | 平仓前全仓 5000万 截至上一日终待实现(250,508.61) + 平掉额末段增量(846.43) | + +**重算事件金额是应结的 3.3 倍**(多出部分 ≈ 剩余 3500 万份额对应的待实现利息,在只平 30% 时被算进重算结果)。 + +> **影响面最终裁定(2026-08-16 代码级核查,三层全部关闭):资金没多结,数据没错,本分歧零生产后果。** +> ①**不落库**:`UpdateDbOption` 仅打审计字段不做 DbSet.Add,且 `DealInterests` 的 hasClose 分支 +> (SwapEodPositionService.cs:524)**未接收返回值**——重算结果被整个丢弃,库里不存在这条错误流水; +> ②**不动钱**:该路径(autoSwap=false)无 AddClientCash;客户资金只在盘中流程与 EOD 自动互换/分红路径发生; +> ③**快照影响需精确表述(2026-08-16 复核修正,原文"不进快照"过宽)**:结算现金流层无影响 +> ——`TdCloseInterest`/`RealizedInterest` 滚动盘中实落库金额(75,999),autoSettled 恒0; +> **但重算结果并非完全被丢弃**:`TdInterestIncome` 四分支中三分支(!hasPreviousEod/全平/默认) +> 直接写重算值,仅"有历史已结且算尾"分支被 DailyAccrual 干净覆盖;复利 mode2/9 的 +> `TdInterestPrincipal` 取 `interests.First().InterestPrincipal`(重算值,次日复利基数种子); +> `FloatRate` 亦取重算值。即"零生产后果"严格=【零结算现金流后果】,accrual 展示/种子字段被改写。 +> 错误的 251,355 因此并非"只存在于内存与日志"。观察日×当日平仓路径(autoSwap=true)确会结现 +> (系统操作_互换),但该路 percent≡1 即"观察日全量结息"的正确语义,:1220 公式即为其设计。 +> +> **⚠️ 落地审查清单(§六实施时必查,关联 GLMS-20260421-0004)**:历史事故(同方法内 mode2/9 本金 +> 反推膨胀~2.3倍)即源于本方法本金口径,:1413+ 复利部分平仓特判块(fullPrincipal 反推)消费 +> InterestPrincipal——改请求形状后须逐一验证该特判块两形状下反推链路等价;TdInterestIncome +> 三分支的重算值改写、TdInterestPrincipal 种子值变化,均须在黄金回放 diff 白名单内显式核对。 +> **结论:这是共享计算器内的语义地雷(percent≡1 复用),非结算错误;修复性质=重构卫生项, +> 与普通重构同优先级;生产数据修正与内网查证全部取消。** + +### 1.2 可复现实测锚点(玩具参数,字符化钉子) + +场景:合约名义本金(mode2)×复利×浮动 FR007×部分平仓 30% +参数:本金 1000,平掉 300,剩余 700;FR007=0.1%,利差=1.0%;[4/27 起息,4/30 平仓] 算头算尾 4 天;重置周期 3 天。 + +| 入口 | 结息额 | 计算方式 | +|---|---|---| +| 盘中平仓试算 | **0.036165** | 平掉额 300 从 4/27 到 4/30 全程复利重放 | +| EOD 平仓后收盘 | **0.059042** | 剩余持仓全部待实现利息(0.05) + 平掉额 4/30 末段增量(0.009042) | + +EOD 比盘中多结 63%(同一分歧在玩具参数下的比例;比例随持有天数/平仓比例变化,真实规模见 §1.1)。 + +## 二、契约验算(确认书公式,模板:国联民生收益互换确认书-现券/ETF 四份一致) + +``` +参考利率(绝对) = ∏[i=1..k] ( 1 + (FR007i + 利差) × di / 365 ) − 1 +结息额(平仓部分) = 实际平掉额 × 参考利率(绝对) +``` + +真实规模手算(利率恒定 2.05% 时 ∏ 可合并): +- 90 天 = 12×7 天 + 6 天末段 → 参考利率 = (1+0.0205×7/365)^12 × (1+0.0205×6/365) − 1 = **0.0050666026** +- 应结 = 1500万 × 0.0050666026 = **75,999.04 元** = 盘中口径(引擎实测一致) + +EOD 口径(全仓待实现 250,508.61 + 末段增量 846.43 = 251,355.08)**无法由契约公式推出**—— +契约里不存在"平掉 30% 却结走 100% 持仓利息"的条款。 + +玩具参数手算(300×[(1+0.011×3/365)×(1+0.011×1/365)−1])= 0.0361652,同样 = 盘中口径。 + +## 三、触发链:谁在 EOD"平仓"(命名澄清) + +**"EOD 平仓后收盘"不是自动平仓本身,是收盘归档任务对当日平仓事件的重算结息。** 完整链条: + +1. **人工盘中部分平仓**(正确):交易员平仓页操作 → `GetUnwindInterests` 传**真实比例 30%** → + 结息 75,999 落 swap_flow_event(正确); +2. **自动归并部分平仓**(平仓当下也正确):`SwapTradeAutoService` 每日任务按券商柜台反向成交流水 + (swap_flow_merge)自动平仓(`AuotoSwapUnwind`,SwapTradeAutoService.cs:1229/1290/1411)—— + `unwindQty` 按实际流水量算,**流水量<持仓量时天然就是部分平仓**;该方法内部同样走盘中语义 + (SwapDealService.cs:1644 传真实 unwindPercent); +3. **当晚 EOD 重算(分歧所在)**:收盘归档 `DealInterests`(SwapEodPositionService.cs:511/:524) + 发现当日有平仓事件 → `EodPostCloseSettle` 惯例传 **closePrecent 恒 1**("结算本次事件覆盖的全部") + → 掉进 `closePrecent == 1m` 全平专属分支(:1220)→ 251,355 覆盖/修正归档。 + +即:**平仓动作本身(人工/自动)当下结息都是对的;错发生在当晚收盘归档重算这一步**。 + +## 四、机制定位(为什么 EOD 会算成这样) + +`SwapDealService.cs:1220` 的 `closePrecent == 1m` 分支,设计意图是**最终全平专属** +(注释原文:"最终全平只重放上一日终之后的新增利息……以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走")—— +全平时该公式正确。 + +但 EOD 平仓后收盘入口的传参惯例是 **closePrecent 恒 1**(见 `InterestCalcRequest.EodPostCloseSettle` 工厂, +"结算本次事件覆盖的全部")。于是 **EOD 部分平仓 30% 也掉进全平专属分支**,把剩余 100% 持仓的 +待实现利息全部结走。数字 1 承载了两种语义:"平仓比例=100%" vs "本次事件全额结息"。 + +## 五、请业务裁决 + +1. **部分平仓(含 EOD 归档重算)应结利息**:是否 = 确认书公式下"实际平掉额 × 全程利率"? + (即盘中口径;真实规模例:平 30% 应结 75,999.04 而非 251,355.08)——契约公式直接支持,推荐。 +2. **最终全平应结利息**:维持现公式"剩余持仓待实现 + 末段增量"(尾差一次带走)——合理,建议维持。 + +## 六、裁决后的修复方案(技术侧已就绪,预计 1~2 天) + +- EOD 入口改为传**真实比例**(实际平掉额/剩余)而非恒 1;部分平仓自然走重放路径(与盘中同语义) +- 全平保留 percent=1,1220 分支回归"全平专属"设计意图 +- `GetInterests` 内 mode2 无条件覆盖/mode9 兜底(2035e1df 粘合)随之退役删除 +- 钉住测试翻转为"两入口相等" oracle 断言;黄金回放 diff 仅允许出现在本格子的 EOD 结息额(=裁决修正值) + +## 七、生产影响面(已关闭,无需内网查证) + +受影响格子:**当日发生部分平仓 × 复利 × mode2/9 × 当晚 EOD 归档重算**——经代码级核查, +该路径重算结果不落库、不动钱、不进快照(详见 §1.1 引注),**生产资金与数据均无影响, +原定内网查证与数据修正清单全部取消**。观察日×当日平仓(autoSwap=true)路径结现, +但 percent≡1 在该语义下正确,非本分歧范围。 diff --git a/项目文档/多租户死代码清理执行计划.md b/项目文档/多租户死代码清理执行计划.md new file mode 100644 index 00000000..74645370 --- /dev/null +++ b/项目文档/多租户死代码清理执行计划.md @@ -0,0 +1,103 @@ +# 多租户死代码清理执行计划(国联民生独家分支 · 校正版) + +> 分支:`glms/feature/1.4.2` +> 编制:2026-08-15 +> 状态:**计划已查明边界,多处需"部署配置确认"后方可执行删除**。本文件是执行清单,不是批准书。 + +## 0. 最关键的两条纠偏(决定本计划能否落地) + +1. **本分支的"公司"不是编译期硬编码,而是运行期配置。** + `BizLogicSingleton` / `MarginCalculation` 的派发依据是 `PS.Config.Company`,其值来自 **DB `appconfig` 表的 `IErpConfig.ErpElement.Company`**,不是 git 分支。所以"国联独家分支"只表示**当前 GLMS 部署把 Company 配成国联**;理论上同一份代码换 Company 配置即可服务别家。 + → 删除任何"非国联"代码,等于**永久剥夺该部署切换/回退到其它家的能力**。这是业务逻辑决策,不是纯技术清理。 + +2. **真实死活取决于独立开关,不止 Company。** + - **衡泰对接**:不受 Company 门控,由 `HedgingSource`(对冲数据源,衡泰=1)与"接收衡泰修改回执"两个独立开关控制。衡泰模型层 26 文件是否死,取决于 GLMS 部署是否开启这两个开关——**不能一律当死代码删**。 + - **保证金 per-company 计算器**:是否死,取决于 GLMS 把 Company 配成哪家 + 是否走 `UniversalMarginCalculation`(见阶段 1 待确认项)。 + +> ⚠️ 因此本计划所有删除动作前,必须先确认两件事: +> (a) GLMS 生产 `appconfig` 的 `Company` 实际值; +> (b) GLMS 生产是否开启衡泰 `HedgingSource` / 接收回执开关、是否配置浙商等 DataSource。 +> 这两点只能问部署负责人或查生产配置,**grep 代码无法判定**。 + +## 1. 死代码总览(按"确定性"分级) + +| 区域 | 规模 | 确定性 | 死因 | +|---|---|---|---| +| 衡泰模型层 `HengTaiModel/` | 26 文件(实测 1712 行) | **中**——取决于衡泰开关 | `HedgingSource`/回执开关未开则死 | +| 衡泰 DataCompare DTO | 4 文件 | 中 | 同上 | +| 6 处 `using HengTaiModel` 未用 | 6 文件各 1 行 | **高** | 确认未引用任何 HengTai 类型 | +| 保证金 per-company 计算器 | 35 生产文件 ≈17,600 行 + 测试副本 ≈7,000 行 | **中**——取决于 Company 配置 + 是否走 Universal | switch 不含国联→default;Universal 生产无调用 | +| 其他家 BizLogic | 34 文件(含 BizLogicZheShang 等) | 中 | Company 非国联则不可达 | +| 浙商空插件 `Plugins/YLErp.Plugins.ZheShang/` | 目录仅 obj/ | **高**(目录已空) | 无源码/无 csproj/无 sln 引用 | +| 前端"衡泰"JS 引用 | 2 处(etradingRule/etradeAccountList、tradeConfirmBook) | 高 | 衡泰相关 | +| 前端 `Is{券商}` 分支 | 少量(**待核实**,见阶段 3) | 中 | 公司/开关门控 | +| 浙商监管报表 `ExtendReport/ZheShang/` | 2 文件 197 行 | **低**——按 DataSource 派发 | 国联是否配置该 DataSource 未知 | + +> 与"上帝类"的关系:实测 `SwapDealService`/`SwapEodPositionService` 内 **0 个 `Is{券商}` 分支**;全仓 66 个券商分支点泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 等 EOD/风控文件。→ 删死代码**几乎不缩减上帝类的领域密度**,只降"仓库表面积/误接风险"。上帝类治理见阶段 5(领域 seam 抽取),与本案正交。 + +## 2. 删除前通用检查清单(每文件必做) + +1. `grep -rn "" --include=*.cs` 全仓(含 `Tools/`、`UnitTestProject/`、`YLErpUnitTest/`、`Plugins/`),确认除自身定义 + 派发 switch/工厂外无第二引用。 +2. 若在 `.csproj` 有 `` 显式引用(非 SDK 通配),删文件同步移除该行。 +3. 若被其它**待删死文件**引用,可一并删并在 commit 说明。 +4. 删除后必须编译验证(**本机无 dotnet SDK**,交 CI 或你本地 `dotnet build`)。编译器比 grep 可靠。 + +## 3. 分阶段执行计划 + +### 阶段 0 — 清 6 处衡泰死 import【零风险,可立即做】 +仅删 `using YLErp.Model.HengTaiModel;` 行(已确认未引用任何类型): +- `YLErpWeb/Controllers/riskController.cs:5` +- `YLErpWeb/App/KafkaTask/ClientReskCheckKafkaTask.cs:5` +- `YLErpDAL/Modules/SwapModule/TRSHedgingOrderService.cs:16` +- `YLErpDAL/Modules/SwapModule/SwapConsumerService.cs:12` +- `YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs:37` +- `YLErpDAL/BLL/EodSettlement/RealtimePnlCalc.cs:26` + +### 阶段 1 — 保证金 per-company 计算器【最高体量,但需先确认】 +**待确认(执行前必答):** +- GLMS `appconfig.Company` 实际值是否为 `国联`? +- `MarginCalculation.cs` 的 `default` 分支对国联是否真的走 `DefaultMarginCalculation`?还是团队意图走 `UniversalMarginCalculation`(当前生产无调用,疑似未接线)? +- 若国联本应走 `Universal`,则 `UniversalMarginCalculation` 是**活代码、须保留并接线**,而非死代码。 + +**确认后,若国联走 Default:** +- **保留**:`DefaultMarginCalculation.cs`(19) `MarginCalculation.cs`(803,调度器) `MarginCalculationBase.cs`(374) `MarginCalcHelper.cs`(662,partial base) `MarginCalcRequests.cs`(182) `MarginCalcException.cs`(13) +- **删除**(生产 `YLErpDAL/BLL/MarginCalculation/`): + GTJAMarginCalculation(2718) ChangJiangMarginCalculation(1369) GFSMMarginCalculation(905) GuoXinJinYangMarginCalculation(800) HuaAnMarginCalculation(749) DongZhengRunHeMarginCalculation(697) FDMarginCalculation(674) BHRSMarginCalculation(550) XiangCaiMarginCalculation(545) RDMarginCalculation(541) GDGZMarginCalculation(513) GLDHMarginCalculation(513,`#if DEBUG` 才可达) DongWuMarginCalculation(507)+`DongWu/`子目录(115+258+95+42) HongYuanMarginCalculation(500) GuoTouMarginCalculation(501) XingZhengMarginCalculation(494) HaiTongMarginCalculation(479) XMXYMarginCalculation(422) ZhaoZhengMarginCalculation(389) ZheQiMarginCalculation(409) HongYeMarginCalculation(327) GQMarginCalculation(333) ZhongLiangMarginCalculation(338) BXMarginCalculation(320) MaoChuanMarginCalculation(318) ZhongJinMarginCalculation(294) SYWGMarginCalculation(254) XingYeMarginCalculation(226) ALQHMarginCalculation(191) GuoHaiMarginCalculation(82) SQMarginCalculation(20) ZhongCaiMarginCalculation(20) **UniversalMarginCalculation(139,待确认)** +- **测试副本同步删**:`YLErpUnitTest/Modules/MarginModule/MarginCalculation/` 下所有 per-company 副本 + `UnitTestProject/Modules/CalcModules/MarginCalculationTest.cs` 中 `ZhaoZheng`/`GuoTou` 用例;并移除 `YLErpUnitTest.csproj` 对应 `` 行。 +- 体量:生产 ≈17,600 行 + 测试 ≈7,000 行。**这是唯一同时降死代码 + 类蔓延的动作。** + +### 阶段 2 — 其他家 BizLogic【需 Company 确认】 +- **保留**:`BizLogicDefault.cs`(基类) `BizLogicSingleton.cs`(派发器) `BizLogicException.cs` `IBizLogic.cs` `BizLogicGuoLian.cs`(活) +- **删除 34 个公司类**:`BizLogicALQH BHRS BX DX DZ FD GDGZ GFSM GT Gldh Gtja GXJY GuangQi GuoMao HaiTong HongYe HongYuan HuaAn HuaXi RD ShanXiGuShou SQ Sywg WCZD XiangYu XingYe XingZheng ZJ ZhaoZheng ZL ZheQi ZhongJiShiHua MaoChuan ZheShang` +- 收口:`BizLogicSingleton.cs` switch 精简为仅 `国联`(+default→GuoLian),消除多租户 dispatch 异味。 + +### 阶段 3 — 前端 + 浙商插件/报表【中风险,需核实】 +- **前端衡泰(高确定)**:`wwwroot/Scripts/app/etradingRule/etradeAccountList.js` + `tradeConfirmBook.js` 中"衡泰"引用删除;`Views/EtradeAccount/Index.cshtml`「衡泰簿记账户」整块删(确认无其它活内容)。 +- **前端 `Is{券商}` 分支(待核实)**:计划初稿列的 `eodExecV2.cshtml:14/67/134`、`TradeMarketReport_Collateral.cshtml:413` 等,**需先 grep 确认是 `CompanyEnum` 还是 `Is浙商`/`Is国元固收` 等开关**,再决定删法。前端实际券商分支极少。 +- **浙商空插件(高确定)**:`Plugins/YLErp.Plugins.ZheShang/` 整目录删(已空,无 sln/csproj 引用;删前 grep 确认)。 +- **浙商报表(低确定)**:`ExtendReport/ZheShang/` 按 **DataSource** 派发,国联是否配置该 DataSource 未知 → **先查生产菜单/配置确认不可达再删**。同类 `DongWu/GeLin/ZheQi` 报表同理。 + +### 阶段 4 — 衡泰模型层【中风险,需开关确认】 +- 仅当确认 GLMS **未开启** `HedgingSource=衡泰` 与"接收衡泰修改回执"开关时,才删 `HengTaiModel/`(26 文件) + `DataCompare/Dto/`(4 文件)。否则保留。 +- 删除前先确认 `DataCompare` 模块整体是否还有非衡泰调用方。 + +### 阶段 5(独立轨道,不在本计划执行)— 上帝类 seam 抽取 +`SwapDealService`/`SwapEodPositionService` 瘦身**不靠删文件**,按 `互换模块独立化最终方案.md` + `互换模块可测性改造Seam实践指南.md` 做领域 seam 抽取;并优先把泄漏在 `RealtimePnlCalc`/`QuotaMonitorService` 的 66 个 `Is{券商}` 分支收回 `BizLogic` 子类。与阶段 0–4 解耦、可并行。 + +## 4. 风险与回滚 +- **编译验证硬门槛**:本机无 SDK,每阶段交 CI 编译通过;错误几乎都来自漏删引用或误删共享基础设施(`MarginCalculationBase`/`MarginCalcHelper`/`MarginCalcRequests`/`MarginCalcException` 务必保留)。 +- **业务决策风险**:删非国联代码 = 永久丧失该部署切换/回退能力。必须业务/架构负责人拍板,不可技术单方面决定。 +- **报表 DataSource 误判**:阶段 3C 须运行时确认。 +- 每阶段独立 commit(`git rm`),便于单阶段回退。 + +## 5. 预估收益(删除全部确认项后) +| 阶段 | 删除行数(估) | 风险 | 类型 | +|---|---|---|---| +| 0 衡泰死 import | 6 行 | 零 | 纯死代码 | +| 1 保证金 | ≈17,600 生产 +7,000 测试 | 低(确认后) | 死代码 + 类蔓延 | +| 2 BizLogic | 34 文件 ≈3,000+ 行 | 低(确认后) | 多租户残留 | +| 3 前端/插件/报表 | 数百行 + 空目录 | 中 | 公司/开关门控 | +| 4 衡泰模型 | 26+4 文件 ≈1,700 行 | 中(开关确认后) | 供应商集成 | +| **合计** | **≈ 29,000+ 行** | — | — | + +> 结论给用户:清死代码是 hygiene 末道工序,能显著降表面积与误接风险,但**不是**上帝类的最佳方向;上帝类最佳方向仍是领域 seam 抽取 + 收回泄漏的券商分支。删前须先确认 GLMS 部署的 Company 值与衡泰/浙商开关。