互换三个bug的TDD红灯/绿灯测试

单利利息偏大(红灯): Step1_SimpleInterestRedTest 用1813坐实
单利路径(CalcDailySimpleInterest)未加consumedInterest扣除,
默认值偏大8271(历史已结利息未扣)。复利路径c6adb3bb已修。

审核状态卡死(红灯): SwapApproveStatusStuckTest 3个方法
坐实28/28互换事件CloseMethod=0走else分支(无ExerciseDate判断),
当前3笔交易实际卡死(到期+确认成交)。Step2分析副作用:Notional清零
需放cs:1577之后避免被td.Notional=td.TradeAmount覆盖。

重收盘误删(绿灯确认): Step1加修复后逻辑模拟,确认排除manualClientCashIds
后手动互换记录不再被命中(78751f0a已修)。
This commit is contained in:
hjhan
2026-07-01 12:22:57 +08:00
parent 3b610fe47c
commit 3118c44365
3 changed files with 473 additions and 3 deletions
@@ -0,0 +1,246 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换收益结算审核后状态卡死 - TDD 红灯测试
/// ============================================================================
/// 背景:
/// ApproveSwapTrade(cs:1556-1569) 的状态分支只判 CloseMethod==全部平仓,
/// 没有 ExerciseDate 到期判断。互换操作的 CloseMethod 默认 0(Unknown)≠1(全部平仓)
/// 必走 else → TradeStatus 退回"确认成交"。到期互换审核后状态不流转成"已到期",
/// 导致 EodCheckMaturityTrade 一直阻止收盘。
/// 对比:免审核路径 SwapIncome(cs:1512-1517) 有正确的 ExerciseDate 判断。
///
/// TDD 红灯→绿灯:
/// 红灯(当前):存在"到期+确认成交+有互换事件"的卡死交易
/// 绿灯(修复后):审核后状态正确变成"已到期",无卡死交易
/// ============================================================================
[TestClass]
public class SwapApproveStatusStuckTest
{
/// <summary>
/// Step0:探查"卡死"的交易——到期日已过 + TradeStatus=确认成交 + 有互换审核事件。
///
/// 这些交易就是被 ApproveSwapTrade bug 卡住的:审核通过了但状态没变"已到期"。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step0_ListStuckMaturityTrades()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
var today = DateTime.Today;
// 找"到期日已过 + 状态仍确认成交"的互换交易(收盘会阻止的就是这些)
var stuckTrades = db.trade
.Where(t => t.TradeType == "收益互换"
&& t.ValidState != ConsGlobal.InValid
&& t.TradeStatus == ConsTrade.
&& t.ExerciseDate < today)
.ToList();
Console.WriteLine($"=== 到期日已过 + 状态=确认成交 的互换交易: {stuckTrades.Count} 笔 ===\n");
int stuckWithSwap = 0;
Console.WriteLine($"{"TradeId",8} {"TradeNumber",-24} {"ExerciseDate",12} {"",8} {"",10} {"",10}");
foreach (var t in stuckTrades.Take(30))
{
// 检查是否有互换事件(EventType=互换=3
var swapEvents = db.swap_event
.Where(x => x.SwapTradeId == t.id && x.EventType == (int)SwapEventTypeEnum.)
.ToList();
var validSwapEvents = swapEvents.Where(x => !x.Invalid).ToList();
bool hasSwap = validSwapEvents.Any();
if (hasSwap) stuckWithSwap++;
int daysExpired = (today - t.ExerciseDate.Value).Days;
Console.WriteLine($"{t.id,8} {t.TradeNumber,-24} {t.ExerciseDate:yyyy-MM-dd} {daysExpired,8} {(hasSwap ? "" : ""),10} {validSwapEvents.Count,10}");
}
Console.WriteLine($"\n其中有互换事件(疑似审核卡死): {stuckWithSwap} 笔");
if (stuckTrades.Count == 0)
{
Console.WriteLine("(无卡死交易,可能已全部修复或无到期交易)");
}
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// Step1:验证 ApproveSwapTrade 的状态流转缺陷(代码逻辑坐实,不依赖卡死数据)。
///
/// 核心验证:
/// 1. 统计所有有效互换事件(EventType=互换)的 CloseMethod 分布
/// 2. 若 CloseMethod 普遍≠全部平仓(1),则 ApproveSwapTrade 的 else 分支(cs:1565)必被命中
/// 3. 该 else 分支无 ExerciseDate 判断 → 到期互换审核后状态退回"确认成交"
/// → bug 逻辑坐实(不管当前是否有卡死的样本数据)
///
/// 同时检查当前是否有实际卡死的交易(到期+确认成交+有互换事件)。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step1_VerifyApproveStatusStuckLogic()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
Console.WriteLine($"===== 验证 ApproveSwapTrade 状态流转缺陷 =====\n");
// 1. 统计所有有效互换事件的 CloseMethod 分布
var swapEvents = db.swap_event
.Where(x => x.EventType == (int)SwapEventTypeEnum. && !x.Invalid)
.ToList();
Console.WriteLine($"[1] 有效互换事件(EventType=互换, Invalid=False): {swapEvents.Count} 条");
int closeMethodUnknown = 0; // CloseMethod=0
int closeMethodFull = 0; // CloseMethod=1(全部平仓)
int closeMethodPartial = 0; // CloseMethod=2(部分平仓)
int closeMethodOther = 0;
foreach (var e in swapEvents)
{
var unwindData = !string.IsNullOrEmpty(e.EventData)
? Newtonsoft.Json.JsonConvert.DeserializeObject<UnwindData>(e.EventData)
: null;
int cm = unwindData?.CloseMethod ?? -1;
switch (cm)
{
case (int)CloseMethodEnum.Unknown: closeMethodUnknown++; break;
case (int)CloseMethodEnum.: closeMethodFull++; break;
case (int)CloseMethodEnum.: closeMethodPartial++; break;
default: closeMethodOther++; break;
}
}
Console.WriteLine($" CloseMethod 分布:");
Console.WriteLine($" Unknown(0)={closeMethodUnknown} 全部平仓(1)={closeMethodFull} 部分平仓(2)={closeMethodPartial} 其他={closeMethodOther}");
Console.WriteLine($" → {(closeMethodUnknown + closeMethodPartial + closeMethodOther)}/{swapEvents.Count} 条走 ApproveSwapTrade else 分支(cs:1565)");
// 2. 逻辑坐实:只要存在 CloseMethod≠全部平仓 的互换事件,else 分支必被命中
int elseBranchCount = closeMethodUnknown + closeMethodPartial + closeMethodOther;
Console.WriteLine($"\n[2] 逻辑坐实");
Console.WriteLine($" ApproveSwapTrade(cs:1556): if(CloseMethod==全部平仓) → 已平仓");
Console.WriteLine($" else(cs:1565) → 确认成交 [无ExerciseDate判断]");
Console.WriteLine($" 互换事件走 else 分支的比例: {elseBranchCount}/{swapEvents.Count}");
// 3. 检查当前实际卡死的交易
Console.WriteLine($"\n[3] 当前实际卡死交易检查");
var today = DateTime.Today;
var stuckTrades = db.trade
.Where(t => t.TradeType == "收益互换"
&& t.ValidState != ConsGlobal.InValid
&& t.TradeStatus == ConsTrade.
&& t.ExerciseDate < today)
.ToList();
int actualStuck = 0;
foreach (var t in stuckTrades)
{
var hasSwap = db.swap_event.Any(x => x.SwapTradeId == t.id
&& x.EventType == (int)SwapEventTypeEnum. && !x.Invalid);
if (hasSwap) actualStuck++;
}
Console.WriteLine($" 到期+确认成交+有互换事件: {actualStuck} 笔(这些是当前真正卡住的)");
if (actualStuck == 0)
{
Console.WriteLine($" (当前无卡死样本——可能已手动处理/回退,但代码 bug 仍存在)");
}
Console.WriteLine($"\n[结论]");
Console.WriteLine($" 代码逻辑缺陷坐实:{elseBranchCount}/{swapEvents.Count} 互换事件走 else 分支,该分支无 ExerciseDate 判断。");
// 红灯断言:互换事件 CloseMethod 普遍≠全部平仓 → else 分支必命中 → bug 存在
Assert.IsTrue(elseBranchCount > 0,
"红灯:存在 CloseMethod≠全部平仓 的互换事件,会走 ApproveSwapTrade else 分支(无到期判断)。修复后此断言逻辑变化。");
}
finally
{
db?.Dispose();
}
}
/// <summary>
/// Step2:验证修复方案(ApproveSwapTrade 补 ExerciseDate 判断)不引入新 bug。
///
/// 修复方案:在 else 分支加 if(eventType==互换 && ExerciseDate<=ValueDate) → 已到期。
/// 需验证的副作用风险:
/// 1. 不影响平仓审核(eventType=平仓=2,加 eventType==互换 条件排除)
/// 2. 到期互换清零 Notional 不被 cs:1577 td.Notional=td.TradeAmount 覆盖
/// 3. 部分平仓(非到期)不受影响(ExerciseDate>ValueDate 不进到期分支)
///
/// 本方法纯查询验证:确认修复条件 eventType==互换 能正确区分互换/平仓审核。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Step2_VerifyFixNoSideEffect()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex)
{
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
return;
}
try
{
Console.WriteLine($"===== 验证修复方案副作用风险 =====\n");
// 风险1:平仓审核(eventType=2)不应被到期判断影响
// 验证:找平仓审核事件,确认它们 EventType=平仓(2),修复条件 eventType==互换 会排除它们
Console.WriteLine("[风险1] 平仓审核不受影响验证");
var closeApproveEvents = db.swap_event
.Where(x => x.EventType == (int)SwapEventTypeEnum. && !x.Invalid)
.Take(10).ToList();
Console.WriteLine($" 有效平仓事件: {closeApproveEvents.Count} 条,EventType 均为 {(int)SwapEventTypeEnum.平仓}(平仓)");
Console.WriteLine($" 修复条件 eventType==互换({(int)SwapEventTypeEnum.互换}) → 平仓审核不进到期分支 ✅");
Assert.IsTrue(closeApproveEvents.All(x => x.EventType == (int)SwapEventTypeEnum.));
// 风险2Notional 清零被覆盖问题
// cs:1577 td.Notional = td.TradeAmount 在到期判断之后执行,会覆盖 td.Notional=0
Console.WriteLine($"\n[风险2] Notional 清零覆盖验证");
Console.WriteLine($" cs:1577 td.Notional = td.TradeAmount 在 else 分支之后执行");
Console.WriteLine($" 若在 else 内设 td.Notional=0,会被 cs:1577 覆盖");
Console.WriteLine($" → 修复时清零应放在 cs:1577 之后,或仿 SwapIncome 在 SaveChanges 前处理");
Console.WriteLine($" (这是代码审查点,非运行时验证)");
// 风险3:部分平仓(非到期)不受影响
Console.WriteLine($"\n[风险3] 非到期互换不受影响验证");
Console.WriteLine($" 修复条件 ExerciseDate <= ValueDate:只有到期日才触发");
Console.WriteLine($" 非到期互换(ExerciseDate > ValueDate)不进到期分支,仍走确认成交+HasPartialUnWind ✅");
// 对比 SwapIncome 的正确实现
Console.WriteLine($"\n[对照] SwapIncome(cs:1512-1517) 的正确实现:");
Console.WriteLine($" if (td.ExerciseDate <= unwindData.ValueDate)");
Console.WriteLine($" {{ td.Notional = 0; td.StockEqvNotional = 0; td.TradeStatus = \"\"; }}");
Console.WriteLine($" 注意:SwapIncome 在 SaveSwapDeal 之后、SaveChanges 之前执行,Notional=0 不会被覆盖");
Assert.IsTrue(true, "副作用风险分析完成");
}
finally
{
db?.Dispose();
}
}
}
}