Merge remote-tracking branch 'dest/glms/feature/1.4.2' into feature/p132_74-risk-engine
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 互换重收盘误删"手动互换"资金记录 - 录制/验证测试(TDD 红灯)
|
||||
/// ============================================================================
|
||||
/// 背景:
|
||||
/// SwapTradeBaseService.ClearSwapPositions 第398-409行按 Action 字符串模糊删除
|
||||
/// ClientCashInCashOut,而"系统操作-互换"这个 Action 手动互换和自动互换共用,
|
||||
/// 且 ClientCashInCashOut 无来源标记字段 → 重收盘会把用户手动互换的资金记录一并删掉。
|
||||
/// 引入点:commit 44e89726(2026-05-14)把 系统操作_互换 加进删除 Action 列表。
|
||||
///
|
||||
/// TDD 红灯→绿灯:
|
||||
/// 红灯(当前):录制一笔"有手动互换"的交易 → 模拟重收盘 → 断言手动资金记录被删(坐实 bug)
|
||||
/// 绿灯(修复后):同测试断言通过(手动记录保留,自动互换记录正确清理)
|
||||
///
|
||||
/// 运行方式:
|
||||
/// 全部标 [Ignore]+[TestCategory("DBRecording")],不进 CI。
|
||||
/// 手动执行:vstest.console.exe UnitTestProject.dll /TestCaseFilter:"TestCategory=DBRecording"
|
||||
/// ============================================================================
|
||||
[TestClass]
|
||||
public class SwapReEodDeleteManualCashRecordTest
|
||||
{
|
||||
private static readonly string GoldenDir = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapReEodDeleteCash");
|
||||
|
||||
/// <summary>
|
||||
/// Step0:探查测试库,列出所有"有手动互换操作"的互换交易,供挑选样本。
|
||||
///
|
||||
/// 筛选条件(同时满足才是有效复现样本):
|
||||
/// 1. swap_event 存在 EventType=互换(3) 且 ClientCashId>0 的记录(手动互换且生成了资金记录)
|
||||
/// 2. 该 ClientCashId 在 ClientCashInCashOut 中真实存在(未被删)
|
||||
/// 3. 该交易有 eod_swap 记录(已收盘过,才能"重收盘")
|
||||
///
|
||||
/// 连不上测试库时 Inconclusive(CI 无 DB 环境正常跳过)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DBRecording")]
|
||||
public void Step0_ListManualSwapTrades()
|
||||
{
|
||||
YLContext db;
|
||||
try { db = DbContextFactory.GetYLDbContext(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 找所有手动互换事件(EventType=互换=3,且关联了资金记录)
|
||||
var manualEvents = db.swap_event
|
||||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换
|
||||
&& x.ClientCashId > 0
|
||||
&& !x.Invalid)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"=== 手动互换事件(EventType=3, ClientCashId>0)数: {manualEvents.Count} ===\n");
|
||||
|
||||
// 按交易分组,附加资金记录和 eod 信息
|
||||
var byTrade = manualEvents
|
||||
.GroupBy(x => x.SwapTradeId)
|
||||
.Select(g =>
|
||||
{
|
||||
var cashIds = g.Select(x => x.ClientCashId).Distinct().ToList();
|
||||
var cashRecords = db.ClientCashInCashOut
|
||||
.Where(c => cashIds.Contains(c.id)).ToList();
|
||||
var eodCount = db.eod_swap.Count(e => e.SwapTradeId == g.Key);
|
||||
var autoEvents = db.swap_event.Count(x => x.SwapTradeId == g.Key
|
||||
&& x.EventType == (int)SwapEventTypeEnum.自动互换 && !x.Invalid);
|
||||
return new
|
||||
{
|
||||
SwapTradeId = g.Key,
|
||||
ManualEventCount = g.Count(),
|
||||
CashIds = cashIds,
|
||||
CashRecordsFound = cashRecords.Count,
|
||||
CashAction = cashRecords.Select(c => c.Action).Distinct().ToList(),
|
||||
CashAmounts = cashRecords.Select(c => c.Money).ToList(),
|
||||
CashHappenDates = cashRecords.Select(c => c.HappenDate).ToList(),
|
||||
EodSwapCount = eodCount,
|
||||
AutoSwapEventCount = autoEvents,
|
||||
// 关键:事件日期范围(重收盘 valueDate <= 此日期会触发删除)
|
||||
MinEventDate = g.Min(x => x.ValueDate),
|
||||
MaxEventDate = g.Max(x => x.ValueDate)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(t => t.EodSwapCount > 0) // 优先有eod的(可重收盘)
|
||||
.ThenByDescending(t => t.ManualEventCount)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"{"TradeId",8} {"手动事件",8} {"资金记录",8} {"Action",-20} {"金额",14} {"eod",6} {"自动互换",8} {"事件日期范围",-24} {"可复现",6}");
|
||||
int reproducible = 0;
|
||||
foreach (var t in byTrade.Take(30))
|
||||
{
|
||||
bool canReproduce = t.EodSwapCount > 0 && t.CashRecordsFound > 0;
|
||||
if (canReproduce) reproducible++;
|
||||
string actionStr = string.Join("|", t.CashAction);
|
||||
string amountStr = t.CashAmounts.Any() ? string.Join("|", t.CashAmounts.Select(m => $"{m:F2}")) : "-";
|
||||
string dateRange = $"{t.MinEventDate:yyyy-MM-dd}~{t.MaxEventDate:yyyy-MM-dd}";
|
||||
Console.WriteLine($"{t.SwapTradeId,8} {t.ManualEventCount,8} {t.CashRecordsFound,8} {actionStr,-20} {amountStr,14} {t.EodSwapCount,6} {t.AutoSwapEventCount,8} {dateRange,-24} {(canReproduce ? "✓" : "✗"),6}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n=== 可复现样本数(有eod+有资金记录): {reproducible} ===");
|
||||
if (reproducible == 0)
|
||||
{
|
||||
Assert.Inconclusive("无可复现样本(需要有 eod + 手动互换资金记录的交易)。请先在测试库构造数据。");
|
||||
}
|
||||
Assert.IsTrue(reproducible > 0, "应存在可复现样本");
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step0b:对单个候选样本做详细诊断,确认"重收盘删除条件"确实会命中手动互换资金记录。
|
||||
///
|
||||
/// 核心验证(不改任何数据,纯查询):模拟 ClearSwapPositions 第400-404行的删除条件,
|
||||
/// 看会命中哪些 ClientCashInCashOut 记录,逐条标注它是"手动互换"还是"自动互换"产生的。
|
||||
/// 如果命中列表里有手动互换的记录 → bug 坐实(红灯前置证据)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DBRecording")]
|
||||
public void Step0b_DiagnoseSingleTradeDeleteCondition()
|
||||
{
|
||||
// 候选样本(从 Step0 输出中挑选):1903=最新,有自动互换,结构完整
|
||||
int tradeId = SampleTradeId;
|
||||
|
||||
YLContext db;
|
||||
try { db = DbContextFactory.GetYLDbContext(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"===== 诊断 SwapTradeId={tradeId} 的资金记录删除命中情况 =====\n");
|
||||
|
||||
// 1. 该交易全部资金记录(互换/预付金相关)
|
||||
var allCashRecords = db.ClientCashInCashOut
|
||||
.Where(x => x.TradeId == tradeId
|
||||
&& (x.Action == ClientCashInCashOut.系统操作_互换
|
||||
|| x.Action == ClientCashInCashOut.系统操作_预付金返息))
|
||||
.OrderBy(x => x.HappenDate).ThenBy(x => x.id)
|
||||
.ToList();
|
||||
Console.WriteLine($"[1] 该交易全部互换/预付金资金记录: {allCashRecords.Count} 条");
|
||||
foreach (var c in allCashRecords)
|
||||
{
|
||||
Console.WriteLine($" id={c.id} Action={c.Action} Money={c.Money:F2} HappenDate={c.HappenDate:yyyy-MM-dd} OptName={c.OptName} CreateDate={c.CreateDate:yyyy-MM-dd HH:mm}");
|
||||
}
|
||||
|
||||
// 2. 该交易全部 swap_event(区分手动互换 vs 自动互换)
|
||||
var allEvents = db.swap_event
|
||||
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
|
||||
.OrderBy(x => x.ValueDate).ThenBy(x => x.id)
|
||||
.ToList();
|
||||
Console.WriteLine($"\n[2] 该交易全部 swap_event: {allEvents.Count} 条");
|
||||
foreach (var e in allEvents)
|
||||
{
|
||||
string etName = ((SwapEventTypeEnum)e.EventType).ToString();
|
||||
Console.WriteLine($" id={e.id} EventType={e.EventType}({etName}) ValueDate={e.ValueDate:yyyy-MM-dd} ClientCashId={e.ClientCashId}");
|
||||
}
|
||||
|
||||
// 3. 关键:找出"自动互换"事件,确定重收盘的删除起点 valueDate
|
||||
var autoEvents = allEvents.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换).ToList();
|
||||
if (autoEvents.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"\n⚠ 该交易无自动互换事件,重收盘不会触发 ClearSwapPositions 的资金删除逻辑。");
|
||||
Console.WriteLine($" 改用 SwapPositionCompose 的合成持仓路径(delAfter=false)也不删资金。");
|
||||
Console.WriteLine($" → 此样本不适合复现,需选有自动互换事件的样本。");
|
||||
Assert.Inconclusive("此样本无自动互换事件,请换一个有自动互换的交易。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 重收盘时 valueDate 取自动互换事件的最小 ValueDate(重收盘从该日起重算)
|
||||
var minAutoDate = autoEvents.Min(x => x.ValueDate);
|
||||
Console.WriteLine($"\n[3] 自动互换事件 {autoEvents.Count} 条,最早 ValueDate={minAutoDate:yyyy-MM-dd}(重收盘 valueDate 起点)");
|
||||
|
||||
// 4. 模拟 ClearSwapPositions 第399-404行的删除条件
|
||||
var swapTradeIds = new List<int> { tradeId };
|
||||
var actions = new List<string> { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||||
var wouldDelete = allCashRecords
|
||||
.Where(x => x.HappenDate >= minAutoDate && actions.Contains(x.Action))
|
||||
.ToList();
|
||||
Console.WriteLine($"\n[4] ⚠ 模拟删除条件(HappenDate>={minAutoDate:yyyy-MM-dd} AND Action IN 互换/预付金返息)会命中: {wouldDelete.Count} 条");
|
||||
|
||||
// 5. 逐条标注命中记录的来源(手动 vs 自动)
|
||||
var manualCashIds = allEvents
|
||||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换 && x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId).ToHashSet();
|
||||
var autoCashIds = allEvents
|
||||
.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换 && x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId).ToHashSet();
|
||||
|
||||
int manualHit = 0, autoHit = 0, unknownHit = 0;
|
||||
Console.WriteLine($" {"id",8} {"Action",-20} {"Money",12} {"HappenDate",-12} {"来源",10} {"⚠误删",6}");
|
||||
foreach (var c in wouldDelete)
|
||||
{
|
||||
string source;
|
||||
bool misDelete = false;
|
||||
if (manualCashIds.Contains(c.id)) { source = "手动互换"; misDelete = true; manualHit++; }
|
||||
else if (autoCashIds.Contains(c.id)) { source = "自动互换"; autoHit++; }
|
||||
else { source = "未知(孤儿)"; unknownHit++; }
|
||||
Console.WriteLine($" {c.id,8} {c.Action,-20} {c.Money,12:F2} {c.HappenDate?.ToString("yyyy-MM-dd"),-12} {source,-10} {(misDelete ? "✓BUG" : ""),6}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n[结论] 删除命中 {wouldDelete.Count} 条 = 手动互换 {manualHit} + 自动互换 {autoHit} + 未知 {unknownHit}");
|
||||
if (manualHit > 0)
|
||||
{
|
||||
Console.WriteLine($"⚠⚠⚠ 坐实 BUG:重收盘会误删 {manualHit} 条手动互换资金记录!");
|
||||
}
|
||||
Console.WriteLine($"\n(以上为纯查询诊断,未修改任何数据)");
|
||||
|
||||
Assert.IsTrue(wouldDelete.Count > 0, "删除条件应至少命中1条");
|
||||
}
|
||||
finally
|
||||
{
|
||||
db?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 候选样本交易ID。从 Step0 输出中选有自动互换事件 + 有手动互换资金记录的交易。
|
||||
/// </summary>
|
||||
private int SampleTradeId => 1903;
|
||||
|
||||
/// <summary>
|
||||
/// Step1:录制样本交易快照 + 模拟删除条件,把"会被误删的手动互换资金记录"固化为 golden。
|
||||
///
|
||||
/// 这是 TDD 红灯的核心产物:
|
||||
/// - 录制该交易的 swap_event + ClientCashInCashOut 完整快照
|
||||
/// - 模拟 ClearSwapPositions:400-404 的删除条件,算出命中列表
|
||||
/// - 标注每条命中记录的来源(手动互换/自动互换/孤儿)
|
||||
/// - 断言"命中列表含手动互换记录" → 当前成立(红灯,坐实 bug)
|
||||
///
|
||||
/// 修复后(绿灯):命中列表应只含自动互换记录,手动互换记录不在内 → 断言失败需更新 golden。
|
||||
///
|
||||
/// 为何不直接调 SwapPositionCompose:
|
||||
/// 那会真删测试库数据且难恢复。录制+模拟条件能等价坐实 bug,又不破坏数据。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DBRecording")]
|
||||
// [Ignore] // 有写文件副作用,手动跑时取消注释
|
||||
public void Step1_RecordAndDiagnoseDeleteBug()
|
||||
{
|
||||
int tradeId = SampleTradeId;
|
||||
YLContext db;
|
||||
try { db = DbContextFactory.GetYLDbContext(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||||
return;
|
||||
}
|
||||
using (db)
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir);
|
||||
|
||||
Console.WriteLine($"\n========== 录制 SwapTradeId={tradeId}(重收盘误删手动资金记录)==========\n");
|
||||
|
||||
// 1. 交易主信息
|
||||
var trade = db.trade.FirstOrDefault(t => t.id == tradeId);
|
||||
Assert.IsNotNull(trade, $"trade {tradeId} 不存在");
|
||||
|
||||
// 2. swap_event 快照(区分手动互换 vs 自动互换)
|
||||
var allEvents = db.swap_event
|
||||
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
|
||||
.OrderBy(x => x.ValueDate).ThenBy(x => x.id)
|
||||
.ToList();
|
||||
|
||||
// 3. ClientCashInCashOut 快照(互换/预付金相关,bug 影响范围)
|
||||
var allCashRecords = db.ClientCashInCashOut
|
||||
.Where(x => x.TradeId == tradeId
|
||||
&& (x.Action == ClientCashInCashOut.系统操作_互换
|
||||
|| x.Action == ClientCashInCashOut.系统操作_预付金返息))
|
||||
.OrderBy(x => x.HappenDate).ThenBy(x => x.id)
|
||||
.ToList();
|
||||
|
||||
// 4. 诊断:模拟删除条件,算出命中列表 + 来源标注
|
||||
var diagnosis = DiagnoseDeleteImpact(tradeId, allEvents, allCashRecords);
|
||||
Console.WriteLine(diagnosis.Summary);
|
||||
|
||||
// 5. 序列化 golden
|
||||
var golden = new ReEodDeleteCashGoldenModel
|
||||
{
|
||||
SwapTradeId = tradeId,
|
||||
SwapTradeNo = trade.TradeNumber,
|
||||
RecordedAt = DateTime.Now,
|
||||
SourceDb = "test",
|
||||
Purpose = "重收盘误删手动互换资金记录 - TDD红灯证据",
|
||||
InputEvents = JArray.FromObject(allEvents, JsonSerializer.Create(JsonSettings)),
|
||||
InputCashRecords = JArray.FromObject(allCashRecords, JsonSerializer.Create(JsonSettings)),
|
||||
Diagnosis = JObject.FromObject(diagnosis, JsonSerializer.Create(JsonSettings))
|
||||
};
|
||||
|
||||
string filePath = Path.Combine(GoldenDir, $"reeod_delete_trade_{tradeId}.json");
|
||||
File.WriteAllText(filePath, JsonConvert.SerializeObject(golden, JsonSettings));
|
||||
Console.WriteLine($"\n✅ golden 已保存: {filePath}");
|
||||
|
||||
// 红灯断言:命中列表必须含手动互换记录(坐实 bug)
|
||||
Assert.IsTrue(diagnosis.手动互换误删记录数 > 0,
|
||||
$"红灯:重收盘删除条件应误删 {diagnosis.手动互换误删记录数} 条手动互换资金记录 " +
|
||||
$"(ids=[{string.Join(",", diagnosis.手动互换误删CashIds)}])。修复后此断言应反转。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 ClearSwapPositions:400-404 的删除条件,诊断命中情况。
|
||||
/// </summary>
|
||||
private DeleteDiagnoseResult DiagnoseDeleteImpact(
|
||||
int tradeId,
|
||||
List<swap_event> allEvents,
|
||||
List<ClientCashInCashOut> allCashRecords)
|
||||
{
|
||||
var r = new DeleteDiagnoseResult { SwapTradeId = tradeId };
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"--- 重收盘误删诊断 SwapTradeId={tradeId} ---",
|
||||
"",
|
||||
"[swap_event] 手动互换 vs 自动互换:"
|
||||
};
|
||||
|
||||
foreach (var e in allEvents)
|
||||
{
|
||||
if (e.EventType == (int)SwapEventTypeEnum.互换 || e.EventType == (int)SwapEventTypeEnum.自动互换)
|
||||
{
|
||||
lines.Add($" event id={e.id} EventType={((SwapEventTypeEnum)e.EventType).ToString()} " +
|
||||
$"ValueDate={e.ValueDate:yyyy-MM-dd} ClientCashId={e.ClientCashId}");
|
||||
}
|
||||
}
|
||||
|
||||
// 重收盘删除起点 = 自动互换事件最小 ValueDate(ClearSwapPositions 的 valueDate 入参)
|
||||
var autoEvents = allEvents.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换).ToList();
|
||||
r.自动互换事件数 = autoEvents.Count;
|
||||
|
||||
if (autoEvents.Count == 0)
|
||||
{
|
||||
r.Summary = string.Join("\n", lines) + "\n\n⚠ 无自动互换事件,删除逻辑不触发。";
|
||||
return r;
|
||||
}
|
||||
|
||||
var minAutoDate = autoEvents.Min(x => x.ValueDate);
|
||||
r.删除起点ValueDate = minAutoDate;
|
||||
|
||||
// 来源标注:按 swap_event.ClientCashId 反查
|
||||
var manualCashIds = allEvents
|
||||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换 && x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId).ToHashSet();
|
||||
var autoCashIds = autoEvents
|
||||
.Where(x => x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId).ToHashSet();
|
||||
|
||||
lines.Add("");
|
||||
lines.Add($"[删除条件模拟] valueDate={minAutoDate:yyyy-MM-dd} Action IN (系统操作-互换, 系统操作-预付金返息)");
|
||||
lines.Add(string.Format(" {0,-8}{1,-22}{2,12}{3,-12}{4,-10}{5,-8}", "id", "Action", "Money", "HappenDate", "来源", "误删?"));
|
||||
|
||||
// 模拟 ClearSwapPositions:400-404 Where 条件
|
||||
var actions = new List<string> { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||||
var wouldDelete = allCashRecords
|
||||
.Where(x => x.HappenDate >= minAutoDate && actions.Contains(x.Action))
|
||||
.ToList();
|
||||
|
||||
r.删除命中总数 = wouldDelete.Count;
|
||||
|
||||
foreach (var c in wouldDelete)
|
||||
{
|
||||
string source;
|
||||
bool misDelete = false;
|
||||
if (manualCashIds.Contains(c.id)) { source = "手动互换"; misDelete = true; r.手动互换误删记录数++; r.手动互换误删CashIds.Add(c.id); }
|
||||
else if (autoCashIds.Contains(c.id)) { source = "自动互换"; r.自动互换命中记录数++; }
|
||||
else { source = "未知(孤儿)"; r.孤儿命中记录数++; }
|
||||
r.命中明细.Add(new DeleteHitItem
|
||||
{
|
||||
CashId = c.id, Action = c.Action, Money = c.Money ?? 0,
|
||||
HappenDate = c.HappenDate, 来源 = source, 会被误删 = misDelete
|
||||
});
|
||||
lines.Add(string.Format(" {0,-8}{1,-22}{2,12:F2}{3,-12}{4,-10}{5,-8}",
|
||||
c.id, c.Action, c.Money, c.HappenDate?.ToString("yyyy-MM-dd"), source, misDelete ? "✓BUG" : ""));
|
||||
}
|
||||
|
||||
lines.Add("");
|
||||
lines.Add("[结论]");
|
||||
lines.Add($" 删除命中 {r.删除命中总数} 条 = 手动互换 {r.手动互换误删记录数} + 自动互换 {r.自动互换命中记录数} + 孤儿 {r.孤儿命中记录数}");
|
||||
if (r.手动互换误删记录数 > 0)
|
||||
{
|
||||
lines.Add($" ⚠⚠⚠ 坐实 BUG:重收盘会误删 {r.手动互换误删记录数} 条手动互换资金记录 (ids=[{string.Join(",", r.手动互换误删CashIds)}])");
|
||||
lines.Add($" 根因:ClearSwapPositions:400 按 Action=系统操作-互换 删除,该 Action 手动/自动共用,无来源字段区分。");
|
||||
r.结论 = $"坐实BUG:误删 {r.手动互换误删记录数} 条手动互换资金记录";
|
||||
r.BUG成立 = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($" 未检测到误删手动互换记录(可能已修复)。");
|
||||
r.结论 = "未检测到误删";
|
||||
r.BUG成立 = false;
|
||||
}
|
||||
|
||||
r.Summary = string.Join("\n", lines);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step2:离线校验已录制 golden 文件(不连库)。
|
||||
/// 确认 json 含完整快照 + 诊断结论能正确反序列化。
|
||||
/// 这是唯一不标 [Ignore] 且能进 CI 的测试(纯读文件,无外部依赖)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[TestCategory("DBRecording")]
|
||||
public void Step2_VerifyRecordedGoldenFile()
|
||||
{
|
||||
if (!Directory.Exists(GoldenDir))
|
||||
{
|
||||
Assert.Inconclusive($"golden 目录不存在: {GoldenDir}(请先跑 Step1_RecordAndDiagnoseDeleteBug)");
|
||||
return;
|
||||
}
|
||||
var files = Directory.GetFiles(GoldenDir, "reeod_delete_trade_*.json");
|
||||
Assert.IsTrue(files.Length > 0, $"应至少有 1 个 golden 文件 in {GoldenDir}");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var golden = JsonConvert.DeserializeObject<ReEodDeleteCashGoldenModel>(json);
|
||||
|
||||
Assert.IsTrue(golden.SwapTradeId > 0, $"{file}: SwapTradeId 无效");
|
||||
Assert.IsNotNull(golden.InputEvents, $"{file}: InputEvents 缺失");
|
||||
Assert.IsTrue(golden.InputEvents.Count > 0, $"{file}: InputEvents 为空");
|
||||
Assert.IsNotNull(golden.InputCashRecords, $"{file}: InputCashRecords 缺失");
|
||||
Assert.IsTrue(golden.InputCashRecords.Count > 0, $"{file}: InputCashRecords 为空");
|
||||
Assert.IsNotNull(golden.Diagnosis, $"{file}: Diagnosis 缺失");
|
||||
|
||||
Console.WriteLine($"✅ {Path.GetFileName(file)}: trade={golden.SwapTradeId}, " +
|
||||
$"events={golden.InputEvents.Count}条, cash={golden.InputCashRecords.Count}条, " +
|
||||
$"BUG成立={golden.Diagnosis?["BUG成立"]?.Value<bool>()}, " +
|
||||
$"结论={golden.Diagnosis?["结论"]?.Value<string>()}");
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Include,
|
||||
DateFormatString = "yyyy-MM-ddTHH:mm:ss",
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重收盘误删 golden 模型:swap_event + ClientCashInCashOut 快照 + 删除命中诊断。
|
||||
/// </summary>
|
||||
public class ReEodDeleteCashGoldenModel
|
||||
{
|
||||
public int SwapTradeId { get; set; }
|
||||
public string SwapTradeNo { get; set; }
|
||||
public DateTime RecordedAt { get; set; }
|
||||
public string SourceDb { get; set; }
|
||||
public string Purpose { get; set; }
|
||||
public JArray InputEvents { get; set; } // swap_event
|
||||
public JArray InputCashRecords { get; set; } // ClientCashInCashOut
|
||||
public JObject Diagnosis { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重收盘删除命中诊断结果。
|
||||
/// </summary>
|
||||
public class DeleteDiagnoseResult
|
||||
{
|
||||
public int SwapTradeId { get; set; }
|
||||
public int 自动互换事件数 { get; set; }
|
||||
public DateTime 删除起点ValueDate { get; set; }
|
||||
public int 删除命中总数 { get; set; }
|
||||
public int 手动互换误删记录数 { get; set; }
|
||||
public int 自动互换命中记录数 { get; set; }
|
||||
public int 孤儿命中记录数 { get; set; }
|
||||
public List<long> 手动互换误删CashIds { get; set; } = new List<long>();
|
||||
public List<DeleteHitItem> 命中明细 { get; set; } = new List<DeleteHitItem>();
|
||||
public bool BUG成立 { get; set; }
|
||||
public string 结论 { get; set; }
|
||||
public string Summary { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteHitItem
|
||||
{
|
||||
public long CashId { get; set; }
|
||||
public string Action { get; set; }
|
||||
public double Money { get; set; }
|
||||
public DateTime? HappenDate { get; set; }
|
||||
public string 来源 { get; set; }
|
||||
public bool 会被误删 { get; set; }
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
{
|
||||
"SwapTradeId": 1903,
|
||||
"SwapTradeNo": "GLMS-20260624-0005",
|
||||
"RecordedAt": "2026-07-01T08:33:12",
|
||||
"SourceDb": "test",
|
||||
"Purpose": "重收盘误删手动互换资金记录 - TDD红灯证据",
|
||||
"InputEvents": [
|
||||
{
|
||||
"ValueDate": "2026-06-24T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 9,
|
||||
"EventTypeName": "确认交易",
|
||||
"EventReason": "确认交易",
|
||||
"EventData": "",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 0,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20625,
|
||||
"EncryptId": "l4jIqgrZepVZBJ8gLfbu_w",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T17:38:40"
|
||||
},
|
||||
{
|
||||
"ValueDate": "2026-06-24T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 5,
|
||||
"EventTypeName": "回退",
|
||||
"EventReason": "交易回退至2026年06月24日",
|
||||
"EventData": "",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 0,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20631,
|
||||
"EncryptId": "lNwpsaP9qzZyFuecPppR4Q",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T17:58:12"
|
||||
},
|
||||
{
|
||||
"ValueDate": "2026-06-29T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 4,
|
||||
"EventTypeName": "自动互换",
|
||||
"EventReason": "系统操作-自动互换",
|
||||
"EventData": "{\"SwapTradeId\":1903,\"CloseType\":0,\"CloseQty\":0.0,\"ClosePercent\":0.0,\"CloseNotionalValue\":0.0,\"StartDate\":\"2026-06-25T00:00:00\",\"ValueDate\":\"2026-06-29T00:00:00\",\"UnwindDate\":null,\"NotionalValue\":19990.0,\"NotionalQty\":0.0,\"PosiNotionalValue\":19990.000000,\"PositionQty\":0.0,\"AnnualDays\":0,\"CloseMethod\":0,\"SwapRealizedPnL\":1.86160315679,\"SwapMarginRebatePnl\":-1.36986460274,\"SwapMarginAmount\":0.0,\"SwapCloseAmount\":1.86160315679,\"SwapDividendPnl\":0.0,\"FlowEvents\":[],\"StructureType\":null,\"PayDate\":\"2026-06-29T00:00:00\",\"TradeStartDate\":null}",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 13031,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20659,
|
||||
"EncryptId": "xgIja4GsGIq0qiL3Q-IdbQ",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T18:08:51"
|
||||
},
|
||||
{
|
||||
"ValueDate": "2026-06-30T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 3,
|
||||
"EventTypeName": "互换",
|
||||
"EventReason": "系统操作-互换",
|
||||
"EventData": "{\"SwapTradeId\":1903,\"CloseType\":0,\"CloseQty\":0.0,\"ClosePercent\":1.0,\"CloseNotionalValue\":19990.0,\"StartDate\":\"2026-06-24T00:00:00\",\"ValueDate\":\"2026-06-30T00:00:00\",\"UnwindDate\":\"2026-06-30T00:00:00\",\"NotionalValue\":19990.00,\"NotionalQty\":1000.00,\"PosiNotionalValue\":19990.00,\"PositionQty\":1000.0,\"AnnualDays\":365,\"CloseMethod\":0,\"SwapRealizedPnL\":3.23,\"SwapMarginRebatePnl\":0.00,\"SwapMarginAmount\":0.0,\"SwapCloseAmount\":3.23,\"SwapDividendPnl\":0.0,\"FlowEvents\":[],\"StructureType\":\"普通债券类收益互换\",\"PayDate\":\"2026-06-30T00:00:00\",\"TradeStartDate\":\"2026-06-25T00:00:00\"}",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 12996,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20632,
|
||||
"EncryptId": "GLfe6-0kN1kS1j8fwI35Jw",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T18:00:12"
|
||||
},
|
||||
{
|
||||
"ValueDate": "2026-06-30T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 3,
|
||||
"EventTypeName": "互换",
|
||||
"EventReason": "系统操作-互换",
|
||||
"EventData": "{\"SwapTradeId\":1903,\"CloseType\":0,\"CloseQty\":0.0,\"ClosePercent\":1.0,\"CloseNotionalValue\":19990.0,\"StartDate\":\"2026-06-30T00:00:00\",\"ValueDate\":\"2026-06-30T00:00:00\",\"UnwindDate\":\"2026-06-30T00:00:00\",\"NotionalValue\":19990.00,\"NotionalQty\":1000.00,\"PosiNotionalValue\":19990.00,\"PositionQty\":1000.0,\"AnnualDays\":365,\"CloseMethod\":0,\"SwapRealizedPnL\":5.00,\"SwapMarginRebatePnl\":0.00,\"SwapMarginAmount\":0.0,\"SwapCloseAmount\":5.00,\"SwapDividendPnl\":0.0,\"FlowEvents\":[],\"StructureType\":\"普通债券类收益互换\",\"PayDate\":\"2026-06-30T00:00:00\",\"TradeStartDate\":\"2026-06-25T00:00:00\"}",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 13029,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20658,
|
||||
"EncryptId": "CGvRD9lBlpTNg44rkLnzvA",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T18:08:07"
|
||||
},
|
||||
{
|
||||
"ValueDate": "2026-06-30T00:00:00",
|
||||
"SwapTradeId": 1903,
|
||||
"EventType": 3,
|
||||
"EventTypeName": "互换",
|
||||
"EventReason": "系统操作-互换",
|
||||
"EventData": "{\"SwapTradeId\":1903,\"CloseType\":0,\"CloseQty\":0.0,\"ClosePercent\":1.0,\"CloseNotionalValue\":19990.0,\"StartDate\":\"2026-06-30T00:00:00\",\"ValueDate\":\"2026-06-30T00:00:00\",\"UnwindDate\":\"2026-06-30T00:00:00\",\"NotionalValue\":19990.00,\"NotionalQty\":1000.00,\"PosiNotionalValue\":19990.00,\"PositionQty\":1000.0,\"AnnualDays\":365,\"CloseMethod\":0,\"SwapRealizedPnL\":5.00,\"SwapMarginRebatePnl\":0.00,\"SwapMarginAmount\":0.0,\"SwapCloseAmount\":5.00,\"SwapDividendPnl\":0.0,\"ClientCashIds\":[],\"FlowEvents\":[],\"StructureType\":\"普通债券类收益互换\",\"PayDate\":\"2026-06-30T00:00:00\",\"TradeStartDate\":\"2026-06-25T00:00:00\"}",
|
||||
"Invalid": false,
|
||||
"ClientCashId": 13032,
|
||||
"BackId": 0,
|
||||
"extenstionData": null,
|
||||
"SwapTradeNumber": null,
|
||||
"unwindData": null,
|
||||
"id": 20660,
|
||||
"EncryptId": "Q5Dzi9hzcXG6b8gXbawLHw",
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptTime": "2026-06-30T18:10:29"
|
||||
}
|
||||
],
|
||||
"InputCashRecords": [
|
||||
{
|
||||
"DirectionType": "互换支出",
|
||||
"TradeDirectionType": "互换收入",
|
||||
"CanGenerateExit": false,
|
||||
"Explain": null,
|
||||
"ValidState": "Valid",
|
||||
"TradeId": 1903,
|
||||
"Action": "系统操作-互换",
|
||||
"Direction": "应收",
|
||||
"Number": "20260630180850883",
|
||||
"ClientId": 38,
|
||||
"ClientNumber": "26033ZMRCS",
|
||||
"ClientName": "张名锐测试",
|
||||
"Money": -3.23,
|
||||
"HappenDate": "2026-06-29T00:00:00",
|
||||
"State": "已结算",
|
||||
"OpenBank": null,
|
||||
"OpenBankId": null,
|
||||
"OpenBankComments": null,
|
||||
"OpenBankCard": "",
|
||||
"MoneyStr": "-3.23",
|
||||
"SerialNumber": null,
|
||||
"BankFlowId": null,
|
||||
"AccountBalance": null,
|
||||
"Uses": null,
|
||||
"SettleDate": "2026-06-29T00:00:00",
|
||||
"Comments": null,
|
||||
"TradeNumber": "GLMS-20260624-0005",
|
||||
"TradeType": null,
|
||||
"CashFlag": 0,
|
||||
"TradeCashId": 0,
|
||||
"IsGroup": 0,
|
||||
"CurrencyCode": "",
|
||||
"ParentTradeNumber": null,
|
||||
"Deal": 0,
|
||||
"ApprovalProcess": 0,
|
||||
"CreatorId": 1,
|
||||
"CreatorName": "初始用户",
|
||||
"CreateDate": "2026-06-30T18:08:51",
|
||||
"ApprovalDate": null,
|
||||
"cash_type": null,
|
||||
"TransferOutAccount": null,
|
||||
"TransferInAccount": null,
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptDate": "2026-06-30T18:08:51",
|
||||
"id": 13030,
|
||||
"EncryptId": "PmbbhBl-gOhCtjzjFAL3MQ"
|
||||
},
|
||||
{
|
||||
"DirectionType": "预付金返息收入",
|
||||
"TradeDirectionType": "预付金返息支出",
|
||||
"CanGenerateExit": false,
|
||||
"Explain": null,
|
||||
"ValidState": "Valid",
|
||||
"TradeId": 1903,
|
||||
"Action": "系统操作-预付金返息",
|
||||
"Direction": "应收",
|
||||
"Number": "20260630180850888",
|
||||
"ClientId": 38,
|
||||
"ClientNumber": "26033ZMRCS",
|
||||
"ClientName": "张名锐测试",
|
||||
"Money": 1.37,
|
||||
"HappenDate": "2026-06-29T00:00:00",
|
||||
"State": "已结算",
|
||||
"OpenBank": null,
|
||||
"OpenBankId": null,
|
||||
"OpenBankComments": null,
|
||||
"OpenBankCard": "",
|
||||
"MoneyStr": "1.37",
|
||||
"SerialNumber": null,
|
||||
"BankFlowId": null,
|
||||
"AccountBalance": null,
|
||||
"Uses": null,
|
||||
"SettleDate": "2026-06-29T00:00:00",
|
||||
"Comments": null,
|
||||
"TradeNumber": "GLMS-20260624-0005",
|
||||
"TradeType": null,
|
||||
"CashFlag": 0,
|
||||
"TradeCashId": 0,
|
||||
"IsGroup": 0,
|
||||
"CurrencyCode": "",
|
||||
"ParentTradeNumber": null,
|
||||
"Deal": 0,
|
||||
"ApprovalProcess": 0,
|
||||
"CreatorId": 1,
|
||||
"CreatorName": "初始用户",
|
||||
"CreateDate": "2026-06-30T18:08:51",
|
||||
"ApprovalDate": null,
|
||||
"cash_type": null,
|
||||
"TransferOutAccount": null,
|
||||
"TransferInAccount": null,
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptDate": "2026-06-30T18:08:51",
|
||||
"id": 13031,
|
||||
"EncryptId": "cR8xg27rKifLQECWC61RFg"
|
||||
},
|
||||
{
|
||||
"DirectionType": "互换支出",
|
||||
"TradeDirectionType": "互换收入",
|
||||
"CanGenerateExit": false,
|
||||
"Explain": null,
|
||||
"ValidState": "Valid",
|
||||
"TradeId": 1903,
|
||||
"Action": "系统操作-互换",
|
||||
"Direction": "应收",
|
||||
"Number": "20260630181033576",
|
||||
"ClientId": 38,
|
||||
"ClientNumber": "26033ZMRCS",
|
||||
"ClientName": "张名锐测试",
|
||||
"Money": -5.0,
|
||||
"HappenDate": "2026-06-30T00:00:00",
|
||||
"State": "已结算",
|
||||
"OpenBank": null,
|
||||
"OpenBankId": null,
|
||||
"OpenBankComments": null,
|
||||
"OpenBankCard": "",
|
||||
"MoneyStr": "-5.00",
|
||||
"SerialNumber": null,
|
||||
"BankFlowId": null,
|
||||
"AccountBalance": null,
|
||||
"Uses": null,
|
||||
"SettleDate": "2026-06-30T00:00:00",
|
||||
"Comments": null,
|
||||
"TradeNumber": "GLMS-20260624-0005",
|
||||
"TradeType": null,
|
||||
"CashFlag": 0,
|
||||
"TradeCashId": 0,
|
||||
"IsGroup": 0,
|
||||
"CurrencyCode": "",
|
||||
"ParentTradeNumber": null,
|
||||
"Deal": 0,
|
||||
"ApprovalProcess": 0,
|
||||
"CreatorId": 1,
|
||||
"CreatorName": "初始用户",
|
||||
"CreateDate": "2026-06-30T18:10:34",
|
||||
"ApprovalDate": null,
|
||||
"cash_type": null,
|
||||
"TransferOutAccount": null,
|
||||
"TransferInAccount": null,
|
||||
"OptId": 1,
|
||||
"OptName": "初始用户",
|
||||
"OptDate": "2026-06-30T18:10:34",
|
||||
"id": 13032,
|
||||
"EncryptId": "NSMb8Sa0Q0Xx-5oOCr_UAw"
|
||||
}
|
||||
],
|
||||
"Diagnosis": {
|
||||
"SwapTradeId": 1903,
|
||||
"自动互换事件数": 1,
|
||||
"删除起点ValueDate": "2026-06-29T00:00:00",
|
||||
"删除命中总数": 3,
|
||||
"手动互换误删记录数": 1,
|
||||
"自动互换命中记录数": 1,
|
||||
"孤儿命中记录数": 1,
|
||||
"手动互换误删CashIds": [
|
||||
13032
|
||||
],
|
||||
"命中明细": [
|
||||
{
|
||||
"CashId": 13030,
|
||||
"Action": "系统操作-互换",
|
||||
"Money": -3.23,
|
||||
"HappenDate": "2026-06-29T00:00:00",
|
||||
"来源": "未知(孤儿)",
|
||||
"会被误删": false
|
||||
},
|
||||
{
|
||||
"CashId": 13031,
|
||||
"Action": "系统操作-预付金返息",
|
||||
"Money": 1.37,
|
||||
"HappenDate": "2026-06-29T00:00:00",
|
||||
"来源": "自动互换",
|
||||
"会被误删": false
|
||||
},
|
||||
{
|
||||
"CashId": 13032,
|
||||
"Action": "系统操作-互换",
|
||||
"Money": -5.0,
|
||||
"HappenDate": "2026-06-30T00:00:00",
|
||||
"来源": "手动互换",
|
||||
"会被误删": true
|
||||
}
|
||||
],
|
||||
"BUG成立": true,
|
||||
"结论": "坐实BUG:误删 1 条手动互换资金记录",
|
||||
"Summary": "--- 重收盘误删诊断 SwapTradeId=1903 ---\n\n[swap_event] 手动互换 vs 自动互换:\n event id=20659 EventType=自动互换 ValueDate=2026-06-29 ClientCashId=13031\n event id=20632 EventType=互换 ValueDate=2026-06-30 ClientCashId=12996\n event id=20658 EventType=互换 ValueDate=2026-06-30 ClientCashId=13029\n event id=20660 EventType=互换 ValueDate=2026-06-30 ClientCashId=13032\n\n[删除条件模拟] valueDate=2026-06-29 Action IN (系统操作-互换, 系统操作-预付金返息)\n id Action MoneyHappenDate 来源 误删? \n 13030 系统操作-互换 -3.232026-06-29 未知(孤儿) \n 13031 系统操作-预付金返息 1.372026-06-29 自动互换 \n 13032 系统操作-互换 -5.002026-06-30 手动互换 ✓BUG \n\n[结论]\n 删除命中 3 条 = 手动互换 1 + 自动互换 1 + 孤儿 1\n ⚠⚠⚠ 坐实 BUG:重收盘会误删 1 条手动互换资金记录 (ids=[13032])\n 根因:ClearSwapPositions:400 按 Action=系统操作-互换 删除,该 Action 手动/自动共用,无来源字段区分。"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Web.Mvc;
|
||||
using YLErp.BLL;
|
||||
using YLErp.BLL.Eod;
|
||||
using YLErp.Configuration;
|
||||
using YLErp.DBModels;
|
||||
@@ -29,6 +30,14 @@ namespace YLErp.Web.Controllers
|
||||
public class SwapTrade2Controller : BaseController
|
||||
{
|
||||
#region 框架合约
|
||||
//按年份判断是否有IB日历,优先用IB,没有则用CHN
|
||||
private string GetBestCountry(int year)
|
||||
{
|
||||
var hasIB = CalendarBLL.GetAllcalendarModel()
|
||||
.Any(c => c.Year == year && "ib".Equals(c.Country, StringComparison.OrdinalIgnoreCase));
|
||||
return hasIB ? "ib" : "chn";
|
||||
}
|
||||
|
||||
[MyAuthorize("交易管理-互换交易")]
|
||||
public ActionResult TradeList(string settleDate = null, string observationDate = null)
|
||||
{
|
||||
@@ -53,12 +62,13 @@ namespace YLErp.Web.Controllers
|
||||
SettlementRules=0,
|
||||
DividendPayDate=0
|
||||
};
|
||||
var tradeDateCountry = GetBestCountry(valuedateBLL.ValueDate.Year);
|
||||
r = new trade()
|
||||
{
|
||||
TradeType = "收益互换",
|
||||
UnderlyingInstrumentType = "Stock",
|
||||
StartDate = QdpCalendarHelper.GetNonHoliday(valuedateBLL.ValueDate.AddDays(1)),
|
||||
TradeDate = valuedateBLL.ValueDate,
|
||||
StartDate = QdpCalendarHelper.GetNonHoliday(valuedateBLL.ValueDate, tradeDateCountry),
|
||||
TradeDate = QdpCalendarHelper.GetNonHolidayDefore(valuedateBLL.ValueDate.AddDays(-1), tradeDateCountry),
|
||||
TraderId = CurUser.UserId,
|
||||
TraderName = CurUser.UserName,
|
||||
MarginTemplateName = "系统默认",
|
||||
@@ -68,7 +78,7 @@ namespace YLErp.Web.Controllers
|
||||
InitialMargin = 0
|
||||
};
|
||||
r.StructureType = "普通债券类收益互换";
|
||||
r.ExerciseDate = QdpCalendarHelper.GetNonHolidayDefore(r.StartDate.Value.AddDays(14));
|
||||
r.ExerciseDate = QdpCalendarHelper.GetNonHolidayDefore(r.StartDate.Value.AddDays(14), GetBestCountry(r.StartDate.Value.Year));
|
||||
tradeExtendJson.FlowBookMode = (int)FlowBookModeEnum.先进先出;
|
||||
r.trade_extend = new trade_extend()
|
||||
{
|
||||
|
||||
@@ -140,15 +140,15 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="formlabel half">成交日期</label>
|
||||
<vue-datepicker :maxdate="maxTradeDate" :holiday="1" v-model="trade.TradeDate"/>
|
||||
<vue-datepicker :holiday="1" v-model="trade.TradeDate" v-on:input="onTradeDateChange"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="formlabel half">开始日期</label>
|
||||
<vue-datepicker :maxdate="maxTradeDate" :holiday="1" v-model="trade.StartDate" v-on:input="changeTradeDate" />
|
||||
<vue-datepicker ref="startDatePicker" :mindate="trade.TradeDate" :holiday="1" v-model="trade.StartDate" v-on:input="onStartDateChange" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="formlabel half">到期日期</label>
|
||||
<vue-datepicker name="ExerciseDate" :holiday="1" v-model="trade.ExerciseDate" v-on:input="changeExerciseDate" />
|
||||
<vue-datepicker ref="exerciseDatePicker" name="ExerciseDate" :holiday="1" v-model="trade.ExerciseDate" v-on:input="changeExerciseDate" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="formlabel half">名义本金</label>
|
||||
|
||||
@@ -179,8 +179,12 @@ const vue = new Vue({
|
||||
if (this.trade.MetaDic['组合标的']) {
|
||||
this.viewState.synthetic = JSON.parse(this.trade.MetaDic['组合标的']);
|
||||
}
|
||||
this.viewState.Extend.TradingPlace = this.trade.MetaDic["交易场所"] || "";
|
||||
this.viewState.Extend.ClearingAgency = this.trade.MetaDic["清算机构"] || "";
|
||||
if (this.trade.MetaDic["交易场所"]) {
|
||||
this.viewState.Extend.TradingPlace = this.trade.MetaDic["交易场所"];
|
||||
}
|
||||
if (this.trade.MetaDic["清算机构"]) {
|
||||
this.viewState.Extend.ClearingAgency = this.trade.MetaDic["清算机构"];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
__init(this);
|
||||
@@ -264,6 +268,41 @@ const vue = new Vue({
|
||||
this.trade.trade_Initial_Margin.MarginValue = this.trade.StockEqvNotional == 0 ? 0 : this.trade.trade_Initial_Margin.MarginValue / this.trade.StockEqvNotional;
|
||||
|
||||
},
|
||||
//成交日期变更时自动修正联动日期
|
||||
onTradeDateChange() {
|
||||
if (this.trade.StartDate < this.trade.TradeDate) {
|
||||
// 开始日期 = 成交日期 + 1天,跳过节假日
|
||||
var d = new Date(this.trade.TradeDate);
|
||||
d.setDate(d.getDate() + 1);
|
||||
while (ylotc.isHoliday(d)) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
this.trade.StartDate = this.formatDate(d);
|
||||
}
|
||||
// 到期日期必须 ≥ 成交日期 且 ≥ 开始日期
|
||||
if (this.trade.ExerciseDate < this.trade.TradeDate || this.trade.ExerciseDate < this.trade.StartDate) {
|
||||
// 到期日期 = 开始日期 + 14天,跳过节假日
|
||||
var sd = new Date(this.trade.StartDate);
|
||||
sd.setDate(sd.getDate() + 14);
|
||||
while (ylotc.isHoliday(sd)) {
|
||||
sd.setDate(sd.getDate() + 1);
|
||||
}
|
||||
this.trade.ExerciseDate = this.formatDate(sd);
|
||||
}
|
||||
this.changeTradeDate();
|
||||
},
|
||||
//开始日期变更时检查到期日期
|
||||
onStartDateChange() {
|
||||
if (this.trade.ExerciseDate < this.trade.StartDate) {
|
||||
var sd = new Date(this.trade.StartDate);
|
||||
sd.setDate(sd.getDate() + 14);
|
||||
while (ylotc.isHoliday(sd)) {
|
||||
sd.setDate(sd.getDate() + 1);
|
||||
}
|
||||
this.trade.ExerciseDate = this.formatDate(sd);
|
||||
}
|
||||
this.changeTradeDate();
|
||||
},
|
||||
//变更交易日期
|
||||
changeTradeDate(force) {
|
||||
this.changeObservationStart();
|
||||
@@ -273,6 +312,16 @@ const vue = new Vue({
|
||||
this.calcNotional();
|
||||
this.refreshDatepicker();
|
||||
this.changeMarginDate();
|
||||
// 动态更新开始日期和到期日期的下限
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.startDatePicker) {
|
||||
this.$refs.startDatePicker.refresh(this.trade.TradeDate, null);
|
||||
}
|
||||
if (this.$refs.exerciseDatePicker) {
|
||||
var minExerciseDate = this.trade.StartDate > this.trade.TradeDate ? this.trade.StartDate : this.trade.TradeDate;
|
||||
this.$refs.exerciseDatePicker.refresh(minExerciseDate, null);
|
||||
}
|
||||
});
|
||||
//this.initMarginRate();
|
||||
},
|
||||
calcNotional(calcPrice) {
|
||||
@@ -488,7 +537,7 @@ const vue = new Vue({
|
||||
},
|
||||
changeInterestType(item) {
|
||||
if (!item.interest_rest_days) {
|
||||
item.interest_rest_days = 1;
|
||||
item.interest_rest_days = 7;
|
||||
}
|
||||
},
|
||||
setUnderlyingCode(data, item) {
|
||||
@@ -519,8 +568,8 @@ const vue = new Vue({
|
||||
item.FloatRateUnderlyingCode = data.Code;
|
||||
if (data.Code.length > 0 && data.Code != '--') {
|
||||
item.IsAnnualized = true;
|
||||
item.interest_rest_days = 1;
|
||||
item.interest_rule = 0;
|
||||
item.interest_rest_days = 7;
|
||||
item.interest_rule = data.Code === 'FR007' ? -1 : 0;
|
||||
} else {
|
||||
item.interest_rule = null;
|
||||
}
|
||||
@@ -987,6 +1036,14 @@ const vue = new Vue({
|
||||
main.message("请输入正确的开始日期格式");
|
||||
return false;
|
||||
}
|
||||
if (StartDate < TradeDate) {
|
||||
main.message("开始日期不能早于成交日期");
|
||||
return false;
|
||||
}
|
||||
if (StartDate > ExerciseDate) {
|
||||
main.message("到期日期不能早于开始日期");
|
||||
return false;
|
||||
}
|
||||
if (!this.trade.AssetId) {
|
||||
main.message("请设置簿记账户");
|
||||
return false;
|
||||
@@ -1283,7 +1340,7 @@ const vue = new Vue({
|
||||
IsAnnualized: true,//是否年化
|
||||
HappenDate: null,//发生日期,
|
||||
Currency: 'CNY',//币种
|
||||
interest_rest_days: 1,//重置频率
|
||||
interest_rest_days: 7,//重置频率
|
||||
interest_rule: null//利率准则
|
||||
}
|
||||
thisObj.getSwapList.push(getSwap);
|
||||
@@ -1321,7 +1378,7 @@ const vue = new Vue({
|
||||
IsAnnualized: true,//是否年化,
|
||||
HappenDate: thisObj.trade.TradeDate,//发生日期,
|
||||
Currency: 'CNY',//币种
|
||||
interest_rest_days: null,//重置频率
|
||||
interest_rest_days: 7,//重置频率
|
||||
interest_rule: null//利率准则
|
||||
}
|
||||
thisObj.marginSwapList.push(getSwap);
|
||||
@@ -1369,7 +1426,7 @@ const vue = new Vue({
|
||||
IsAnnualized: false,//是否年化
|
||||
HappenDate: null,//发生日期,
|
||||
Currency: 'CNY',//币种
|
||||
interest_rest_days: null,//重置频率
|
||||
interest_rest_days: 7,//重置频率
|
||||
interest_rule: null//利率准则
|
||||
}
|
||||
thisObj.paySwapList.push(petSwap);
|
||||
@@ -1441,7 +1498,7 @@ const vue = new Vue({
|
||||
maxTradeDate() {
|
||||
let exerciseDate = this.trade.ExerciseDate;
|
||||
return exerciseDate && exerciseDate <= page.valuedate ? exerciseDate : page.valuedate;
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
'vue-datepicker': FastVue.vueDatePicker(),
|
||||
|
||||
@@ -13,7 +13,7 @@ ylotc.isHoliday = function (date) {
|
||||
return false;
|
||||
}
|
||||
var y = date.getFullYear().toString();
|
||||
var holidays = ylotc.calendar[y];
|
||||
var holidays = ylotc.calendar[y + "ib"] || ylotc.calendar[y];
|
||||
if (Array.isArray(holidays)) {
|
||||
var d = date.getDate();
|
||||
var m = date.getMonth() + 1;//js月份从0开始
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
# 互换重收盘误删"手动互换"资金记录:问题分析与修复方案
|
||||
|
||||
> 本文分析互换交易"重新收盘/重算 EOD"后,**用户手动互换产生的资金记录被错误删除**的问题:根因、引入时间、三种修复方案与自测方法。
|
||||
> 成文于 2026-07-01,排查范围覆盖 `5f97909f`(2026-04-29)至当前 HEAD。
|
||||
|
||||
---
|
||||
|
||||
## 一、问题概述
|
||||
|
||||
互换交易每次"重新收盘"(EOD 重算)时,会先清理旧数据再重算。当前实现会把**用户手动互换**产生的资金记录(`ClientCashInCashOut` 表中 `Action=系统操作-互换` 的记录)**一并删除**,导致历史资金流水丢失。
|
||||
|
||||
**复现条件**:一笔互换交易,用户已手动做过互换(EventType=3,生成 `Action=系统操作-互换` 资金记录),随后对该交易日重新收盘 → 该手动互换资金记录消失。
|
||||
|
||||
---
|
||||
|
||||
## 二、先澄清概念:哪些表是什么
|
||||
|
||||
| 表名 | 实体类 | 用途 | 与本问题关系 |
|
||||
|------|--------|------|-------------|
|
||||
| `ClientCashInCashOut` | `ClientCashInCashOut.cs:232` | **资金记录/出入金流水**(金额、方向、银行账户)| ⚠️ **被误删的就是这张表** |
|
||||
| `swap_event` | `SwapEvent.cs` | 互换事件主记录(含 `ClientCashId` 外键)| 删除时按 EventType 过滤,安全 |
|
||||
| `swap_flow_event` | `SwapFlowEvent.cs:14` | 开平仓事件明细(盈亏/计息),通过 `ClientCashId` 关联资金记录 | EOD 清理时按 EventType 过滤,**未误删** |
|
||||
| ~~swap_capital_flow / swap_fund_flow~~ | 不存在 | —— | 代码库中无此表 |
|
||||
|
||||
**关键**:`swap_flow_event` 不是"资金记录",它和 `ClientCashInCashOut` 通过 `ClientCashId` 字段 1:1 关联。本问题中 `swap_flow_event` 没被误删,被误删的是 `ClientCashInCashOut`。
|
||||
|
||||
---
|
||||
|
||||
## 三、根因:删除逻辑按 Action 字符串过滤,无法区分手动/自动
|
||||
|
||||
### 问题代码位置
|
||||
|
||||
`YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs` 的 `ClearSwapPositions` 方法,**第 398–409 行**:
|
||||
|
||||
```csharp
|
||||
// SwapTradeBaseService.cs:398-409(bug 所在)
|
||||
var swapTradeIds = swapEvents.Select(s => s.SwapTradeId).Distinct().ToList();
|
||||
var actions = new List<string>() { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||||
var premiumCashRecords = DbContext.ClientCashInCashOut
|
||||
.Where(x => swapTradeIds.Contains(x.TradeId ?? 0)
|
||||
&& x.HappenDate >= valueDate
|
||||
&& actions.Contains(x.Action))
|
||||
.ToList();
|
||||
if (premiumCashRecords.Any())
|
||||
{
|
||||
DbContext.ClientCashInCashOut.RemoveRange(premiumCashRecords);
|
||||
}
|
||||
```
|
||||
|
||||
### 为什么会误删
|
||||
|
||||
`系统操作_互换` 这个 Action 字符串**手动互换和自动互换共用**,而 `ClientCashInCashOut` 表**没有任何字段区分手动 vs 自动来源**:
|
||||
|
||||
| 来源 | 写入位置 | Action 值 |
|
||||
|------|---------|----------|
|
||||
| 手动互换 `SwapIncome` | `SwapDealService.cs:1471` | `系统操作-互换` |
|
||||
| 手动互换 `ApproveSwapTrade`(审批通过)| `SwapDealService.cs:1519` | `系统操作-互换` |
|
||||
| 自动互换(利息腿)| `SwapEodPositionService.cs:516` | `系统操作-互换` |
|
||||
| 自动互换(分红)| `SwapEodPositionService.cs:531` | `系统操作-互换` |
|
||||
|
||||
`AddClientCashInCashOut`(`SwapTradeBaseService.cs:285-318`)生成资金记录时**不设任何来源标记**。所以按 `TradeId + HappenDate>=valueDate + Action` 删除,无法过滤掉手动互换的记录。
|
||||
|
||||
> 注:第 391–396 行按 `swap_event.ClientCashId` 精准删除是**安全**的(只删自动互换 event 自己链接的那条)。**第 398–409 行的"按 Action 兜底模糊删除"才是误删元凶。**
|
||||
|
||||
---
|
||||
|
||||
## 四、引入时间线:是"修每次收盘多出一条"修出来的新 bug
|
||||
|
||||
本 bug 是修复"重复收盘导致互换收入重复计入"时,删除范围逐步扩大,最终修过头引入的。三次提交演进:
|
||||
|
||||
| 时间 | 提交 | 作者 | 改动 | 性质 |
|
||||
|------|------|------|------|------|
|
||||
| **2026-04-29** | `5f97909f` | hjhan | `fix(swap): 修复重复收盘导致互换收入(含预付金利息)重复计入的问题`——在删 `swap_event` 前按 `swap_event.ClientCashId` 精准删除关联资金记录 | ✅ **正确** |
|
||||
| **2026-05-11** | `ebe711d3` | 吴方海 | `#EQD-5850 预付金返息的发生时间有问题`——自动互换把预付金返息单独拆条记录,并在删除时**新增按 `TradeId + 日期 + Action=预付金返息` 批删** | ⚠️ 首次把删除从"按主键ID"放宽到"按Action字符串" |
|
||||
| **2026-05-14** | `44e89726` | 吴方海 | `收盘自动互换,需要将当前计息基数给回到持仓名义本金或固定值`——**把 `系统操作_互换` 加进删除 Action 列表** | ❌ **误删手动互换的直接引入点** |
|
||||
|
||||
`44e89726` 的 diff(实测验证)只改一行:
|
||||
|
||||
```diff
|
||||
- && x.Action == ClientCashInCashOut.系统操作_预付金返息)
|
||||
+ var actions = new List<string>() { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||||
+ && actions.Contains(x.Action))
|
||||
```
|
||||
|
||||
加进去的理由是"自动互换的互换主金额也要清掉重算",但没意识到 `系统操作_互换` 跟手动互换共用。
|
||||
|
||||
---
|
||||
|
||||
## 五、约束分析:为什么不能简单回退到 `5f97909f`
|
||||
|
||||
`5f97909f` 的精准删除(按 `swap_event.ClientCashId`)在**当时**是对的,但现在的自动互换逻辑已演进——**一次自动互换会生成多条资金记录**:
|
||||
|
||||
`SaveAutoSwapDeal`(`SwapEodPositionService.cs:506`)按条件最多生成 3 条 `ClientCashInCashOut`:
|
||||
- `:516` 利息腿 `系统操作_互换`(`SwapCloseAmount != 0` 时)
|
||||
- `:522` 预付金 `系统操作_预付金返息`(`SwapMarginRebatePnl != 0` 时)
|
||||
- `:531` 分红 `系统操作_互换`(`SwapDividendPnl != 0` 时)
|
||||
|
||||
但 `swap_event.ClientCashId`(`:535`)**只指向最后一条非 0 的 id**,前面的被覆盖。所以单纯按 `ClientCashId` 删,会**漏删**自动互换的前几条资金记录 → 复发"每次收盘多出一条"老 bug(`5f97909f` 当时修的那个)。
|
||||
|
||||
> 手动互换也有同样结构:`SwapIncome:1471` 生成主金额(id 存进 event),`:1474` 预付金返息的 id 被丢弃。手动互换若被精准删除只删主金额那条,预付金返息会成"孤儿"残留——但手动互换**本就不该被重收盘删除**,所以这反而是"正确残留"。
|
||||
|
||||
---
|
||||
|
||||
## 六、修复方案(三选一,供团队决策)
|
||||
|
||||
### 方案 A(推荐,最小改动 + 治本):按 swap_event 链 + EventType 区分
|
||||
|
||||
**思路**:删除自动互换资金记录时,**只删与自动互换 swap_event 关联的记录**,不碰手动互换的 swap_event(EventType=3/互换)。
|
||||
|
||||
删除条件从"按 Action 字符串"改为"按 `swap_event.ClientCashId` 集合",并扩展 `swap_event` 与资金记录的关联(让被覆盖的前几条也能被找到):
|
||||
|
||||
```csharp
|
||||
// 修复后(SwapTradeBaseService.cs:398-409 替换)
|
||||
// 注意:本方法入参 eventTypes 调用方只传了 {自动互换}(SwapEodPositionService.cs:92),
|
||||
// 所以 swapEvents 只含自动互换事件,手动互换事件不在内 —— 这就是安全的关键。
|
||||
var swapEventIds = swapEvents.Select(s => s.id).ToList();
|
||||
if (swapEventIds.Any())
|
||||
{
|
||||
// 已有:按 swap_event.ClientCashId 精准删除主金额那条(保留第391-396行不变)
|
||||
|
||||
// 新增:按 swap_flow_event.ClientCashId 反查自动互换生成的全部资金记录
|
||||
// 自动互换的每条 swap_flow_event 都带 ClientCashId(指向其资金记录)
|
||||
var autoCashIds = DbContext.swap_flow_event
|
||||
.Where(x => swapEventIds.Contains(x.EventId ?? 0) && x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
// 合并 swap_event.ClientCashId(主金额)
|
||||
var eventCashIds = swapEvents.Where(s => s.ClientCashId > 0).Select(s => s.ClientCashId.Value);
|
||||
autoCashIds = autoCashIds.Union(eventCashIds).ToList();
|
||||
|
||||
if (autoCashIds.Any())
|
||||
{
|
||||
var autoCashRecords = DbContext.ClientCashInCashOut
|
||||
.Where(x => autoCashIds.Contains(x.id))
|
||||
.ToList();
|
||||
DbContext.ClientCashInCashOut.RemoveRange(autoCashRecords);
|
||||
}
|
||||
}
|
||||
// ❌ 删除原来的"按 Action 字符串模糊删除"整段(第398-409行)
|
||||
```
|
||||
|
||||
**⚠️ 关键前提缺陷(已确认)**:当前 `SaveAutoSwapDeal:538-542` 生成 flow_event 时**只设了 `EventId`,没有给 `ClientCashId` 赋值**:
|
||||
|
||||
```csharp
|
||||
// SwapEodPositionService.cs:538-542(现状,缺 ClientCashId 赋值)
|
||||
flowEvents.ForEach(x =>
|
||||
{
|
||||
x.EventId = swapEvent.id;
|
||||
DbContext.swap_flow_event.Add(x);
|
||||
});
|
||||
```
|
||||
|
||||
对比平仓路径 `ComposePage:685-686` 是有赋值的(`x.ClientCashId = clientCashId`)。所以**方案 A 必须同时补这个赋值**才能落地,否则反查 `swap_flow_event.ClientCashId` 会漏(复发"每次收盘多出一条"老 bug)。这是方案 A 的硬性前置改动,见第七节"改动 2"。
|
||||
|
||||
**优点**:
|
||||
- 删除范围严格限定在自动互换事件链内,手动互换零误伤。
|
||||
- 不新增字段,不改数据库结构。
|
||||
- 语义清晰:删哪个 event,就删它关联的资金记录。
|
||||
|
||||
**风险**:
|
||||
- 依赖 `swap_flow_event.ClientCashId` 的赋值完整性——当前自动互换分支未设,需补(改动 2)。
|
||||
- **资金记录与 flow_event 不是 1:1**(已确认):
|
||||
- `:420` 普通自动互换:生成 2 条资金记录(利息汇总 `:516` + 预付金汇总 `:522`),flow_event 是利息明细腿(多条)。
|
||||
- `:498` 分红型自动互换:生成 1 条资金记录(分红汇总 `:531`),flow_event 是分红明细腿(多条)。
|
||||
- 所以一条资金记录会被多条 flow_event 引用——改动 2 给 flow_event 设 ClientCashId 时,让同一笔互换的所有明细腿都指向该次生成的资金记录 id(利息腿指向 `:516` 的 id,预付金腿指向 `:522` 的 id)。删除时 `Distinct()` 去重即可。
|
||||
- 由于 `clientCashId` 变量在 `SaveAutoSwapDeal:516/522/531` 被多次覆盖,最终 `swap_event.ClientCashId`(`:535`)只存最后一条。**改动 2 必须在每次 `AddClientCashInCashOut` 后立即把 id 赋给对应的 flow_event 组**,不能依赖最后的 `clientCashId` 值。需配套单测兜底(见第八节)。
|
||||
|
||||
---
|
||||
|
||||
### 方案 B(保守):保留 Action 过滤 + 加来源标记字段
|
||||
|
||||
**思路**:给 `ClientCashInCashOut` 加一个来源字段区分手动/自动,删除时排除手动。
|
||||
|
||||
```csharp
|
||||
// 1. 数据库迁移:ClientCashInCashOut 加列
|
||||
ALTER TABLE ClientCashInCashOut ADD COLUMN Source VARCHAR(20) DEFAULT '手动';
|
||||
|
||||
// 2. AddClientCashInCashOut 增加来源参数(SwapTradeBaseService.cs:285)
|
||||
public int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate, string source = "手动")
|
||||
{
|
||||
...
|
||||
ee.Source = source; // 新增
|
||||
...
|
||||
}
|
||||
|
||||
// 3. 自动互换调用处传 source="自动"(SwapEodPositionService.cs:516/522/531)
|
||||
|
||||
// 4. 删除过滤加排除条件(SwapTradeBaseService.cs:401-404)
|
||||
.Where(x => swapTradeIds.Contains(x.TradeId ?? 0)
|
||||
&& x.HappenDate >= valueDate
|
||||
&& actions.Contains(x.Action)
|
||||
&& x.Source == "自动") // 新增:只删自动生成的
|
||||
```
|
||||
|
||||
**优点**:删除逻辑最直白,改一处过滤条件即可。
|
||||
|
||||
**缺点**:
|
||||
- 需要数据库迁移(生产加列),风险高。
|
||||
- 历史数据 `Source` 为默认值"手动",但历史自动互换记录也是 NULL/默认 → 需数据回填脚本,否则老 bug 数据残留。
|
||||
- `Source` 字段语义需长期维护。
|
||||
|
||||
---
|
||||
|
||||
### 方案 C(最长远,工程量大):用 swap_event 作为唯一删除入口
|
||||
|
||||
**思路**:所有资金记录都必须挂到某个 `swap_event` 上(通过 `swap_flow_event.ClientCashId` 或新增 `ClientCashInCashOut.SwapEventId`),删除时一律按 event 链,彻底废弃按 Action 字符串删除。
|
||||
|
||||
**优点**:语义最清晰,根除"按 Action 字符串删除"这类隐患。
|
||||
|
||||
**缺点**:改动面大,需梳理所有生成资金记录的路径,确保都有 event 挂载。适合作为长期重构目标,不适合当下快速止血。
|
||||
|
||||
---
|
||||
|
||||
### 方案对比
|
||||
|
||||
| 维度 | 方案A(推荐)| 方案B | 方案C |
|
||||
|------|-------------|-------|-------|
|
||||
| 改动量 | 小(1个方法 + flow_event赋值补全)| 中(加列+迁移+多处调用)| 大(全链路重构)|
|
||||
| 数据库迁移 | 否 | **是** | 视实现 |
|
||||
| 治本程度 | 高 | 高 | 最高 |
|
||||
| 风险 | 需确认 flow_event.ClientCashId 完整性 | 迁移风险+历史数据回填 | 改动面大 |
|
||||
| 止血速度 | 快 | 慢 | 慢 |
|
||||
|
||||
---
|
||||
|
||||
## 七、具体小修复代码(方案 A)
|
||||
|
||||
### 改动 1:`SwapTradeBaseService.cs:398-409` 替换删除逻辑
|
||||
|
||||
```csharp
|
||||
// ❌ 删除原第398-409行(按 Action 字符串模糊删除)
|
||||
// ✅ 替换为:按自动互换 swap_event 链精准删除
|
||||
|
||||
// 自动互换一次可能生成多条资金记录(利息腿/预付金/分红),
|
||||
// 但 swap_event.ClientCashId 只指向最后一条。通过 swap_flow_event.ClientCashId
|
||||
// 反查自动互换事件关联的全部资金记录,避免误伤手动互换(EventType=3)。
|
||||
var autoFlowCashIds = DbContext.swap_flow_event
|
||||
.Where(x => eventIds.Contains(x.EventId ?? 0) && x.ClientCashId > 0)
|
||||
.Select(x => x.ClientCashId.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var autoEventCashIds = swapEvents.Where(s => s.ClientCashId > 0).Select(s => s.ClientCashId.Value).ToList();
|
||||
autoFlowCashIds.AddRange(autoEventCashIds);
|
||||
autoFlowCashIds = autoFlowCashIds.Distinct().ToList();
|
||||
|
||||
if (autoFlowCashIds.Any())
|
||||
{
|
||||
var autoCashRecords = DbContext.ClientCashInCashOut
|
||||
.Where(x => autoFlowCashIds.Contains(x.id))
|
||||
.ToList();
|
||||
DbContext.ClientCashInCashOut.RemoveRange(autoCashRecords);
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:`eventIds` 已在第 377 行算好(`swapEvents.Select(s => s.id)`)。本方法入参 `eventTypes` 在重收盘入口(`SwapEodPositionService.cs:92`)只传 `{自动互换}`,所以 `swapEvents` 只含自动互换事件,手动互换事件天然不在删除范围内 —— 这是安全的根本保证。
|
||||
|
||||
### 改动 2(前提):补全 `swap_flow_event.ClientCashId` 赋值
|
||||
|
||||
自动互换 `SaveAutoSwapDeal`(`SwapEodPositionService.cs:506`)当前生成 flow_event 时只设了 `EventId`,没设 `ClientCashId`(`:538-542`、`:549-553`)。需补上,让方案 A 反查能找到全部资金记录。
|
||||
|
||||
由于资金记录是"汇总"的(利息汇总/预付金汇总/分红汇总各 1 条),而 flow_event 是"明细腿"(多条),赋值规则是:**同一笔互换的明细腿都指向该次生成的资金记录 id**。需要在 `SaveAutoSwapDeal` 内用独立变量保存每条资金记录 id(不能依赖被覆盖的 `clientCashId`):
|
||||
|
||||
```csharp
|
||||
// SwapEodPositionService.cs:512-532 改造(用独立变量分别记录每个资金记录 id)
|
||||
int interestCashId = 0; // 利息腿资金记录 id
|
||||
int premiumCashId = 0; // 预付金腿资金记录 id
|
||||
int dividendCashId = 0; // 分红资金记录 id
|
||||
|
||||
if (unwindData.SwapCloseAmount != 0)
|
||||
{
|
||||
interestCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, cashHappenDate);
|
||||
}
|
||||
if (unwindData.SwapMarginRebatePnl != 0)
|
||||
{
|
||||
premiumCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut.系统操作_预付金返息, unwindData.ValueDate);
|
||||
}
|
||||
unwindData.SwapCloseAmount = unwindData.SwapRealizedPnL;
|
||||
if (unwindData.SwapDividendPnl != 0)
|
||||
{
|
||||
var dividendPayDate = (dividendEvents != null && dividendEvents.Count > 0)
|
||||
? dividendEvents.First().PayDate.Value : unwindData.ValueDate;
|
||||
dividendCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut.系统操作_互换, dividendPayDate);
|
||||
}
|
||||
|
||||
// 保留 clientCashId 给 swap_event(取最后一条非0,与原行为一致)
|
||||
int clientCashId = dividendCashId != 0 ? dividendCashId : (premiumCashId != 0 ? premiumCashId : interestCashId);
|
||||
```
|
||||
|
||||
然后在 flow_event 赋值处按腿类型设 `ClientCashId`:
|
||||
|
||||
```csharp
|
||||
// SwapEodPositionService.cs:536-555(flowEvents 是利息明细腿,指向利息资金记录)
|
||||
if (flowEvents != null)
|
||||
{
|
||||
flowEvents.ForEach(x =>
|
||||
{
|
||||
x.EventId = swapEvent.id;
|
||||
x.ClientCashId = interestCashId; // 补:利息明细腿指向利息汇总资金记录
|
||||
DbContext.swap_flow_event.Add(x);
|
||||
});
|
||||
UpdateInitalPostion(flowEvents, td.id);
|
||||
}
|
||||
// dividendEvents 是分红明细腿,指向分红资金记录
|
||||
if (dividendEvents != null)
|
||||
{
|
||||
dividendEvents.ForEach(x =>
|
||||
{
|
||||
x.EventId = swapEvent.id;
|
||||
x.ClientCashId = dividendCashId; // 补:分红明细腿指向分红资金记录
|
||||
DbContext.swap_flow_event.Add(x);
|
||||
});
|
||||
UpdateInitalPostion(dividendEvents, td.id);
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ 预付金资金记录 `premiumCashId` 没有对应的 flow_event 可挂载(预付金是单独汇总的,无明细腿)。它只能靠 `swap_event.ClientCashId`(当它是最后一条时)或方案 A 删除逻辑里单独处理。**实施时需验证:当只有预付金非0时,`swap_event.ClientCashId` 是否指向预付金那条**(按上面 `clientCashId` 三元表达式,`premiumCashId != 0` 时会取它,✓ 覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 八、如何自测
|
||||
|
||||
### 自测 1:SQL 验证(最快,纯查询)
|
||||
|
||||
找一笔**当天有手动互换操作**的互换交易,重收盘前后对比资金记录:
|
||||
|
||||
```sql
|
||||
-- 步骤1:盘前记录手动互换资金记录的 id 快照
|
||||
SELECT id, TradeId, HappenDate, Action, Money, State, OptName, OptTime
|
||||
FROM ClientCashInCashOut
|
||||
WHERE TradeId = <手动互换过的交易ID>
|
||||
AND Action = '系统操作-互换'
|
||||
ORDER BY id;
|
||||
-- 记下这些 id(比如 id 列表 = 1001, 1002)
|
||||
|
||||
-- 步骤2:对该交易日执行重收盘(前端操作或调 SwapPositionCompose)
|
||||
|
||||
-- 步骤3:盘后再查同样的 id
|
||||
SELECT id, TradeId, Action, Money
|
||||
FROM ClientCashInCashOut
|
||||
WHERE id IN (1001, 1002); -- 替换为步骤1记下的 id
|
||||
```
|
||||
|
||||
**判断标准**:
|
||||
- **修复前(bug)**:步骤3 查不到记录(被 `RemoveRange` 删除)。
|
||||
- **修复后(正确)**:步骤3 记录原样存在(id/Money/OptName 不变)。
|
||||
|
||||
### 自测 2:守恒校验(验证没修过头 → 自动互换仍能清理)
|
||||
|
||||
确保修复后**自动互换的重复资金记录仍被清理**,否则"每次收盘多出一条"老 bug 复发:
|
||||
|
||||
```sql
|
||||
-- 对一笔纯自动互换交易,连续重收盘 3 次,每次收盘后查记录数
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM ClientCashInCashOut
|
||||
WHERE TradeId = <自动互换交易ID>
|
||||
AND Action IN ('系统操作-互换', '系统操作-预付金返息')
|
||||
AND HappenDate >= '<收盘日>';
|
||||
```
|
||||
|
||||
**判断**:连跑 3 次后 `cnt` 应**稳定不变**(每次清掉重算),**不应**线性递增(若递增说明自动互换资金漏删,老 bug 复发)。
|
||||
|
||||
### 自测 3:录制测试(复用已有 golden 模式,可进回归)
|
||||
|
||||
参考本仓库 `UnitTestProject/Modules/SwapModule/SwapDividendGoldenRecordTest.cs` 的录制模式(连真实测试库 `192.168.2.96/glms_yltrs_ylcms`),骨架:
|
||||
|
||||
```csharp
|
||||
[Ignore] // 有写副作用,标 Ignore 不进 CI;手动跑
|
||||
[TestCategory("DBRecording")]
|
||||
public void ReEod_ShouldNotDeleteManualSwapCashRecord()
|
||||
{
|
||||
int tradeId = <有手动互换的交易ID>;
|
||||
DateTime settleDate = <收盘日>;
|
||||
|
||||
using var ctx = DbContextFactory.GetYLDbContext();
|
||||
|
||||
// 盘前快照:手动互换资金记录 id
|
||||
var manualCashIds = ctx.ClientCashInCashOut
|
||||
.Where(x => x.TradeId == tradeId
|
||||
&& x.Action == ClientCashInCashOut.系统操作_互换
|
||||
&& x.HappenDate >= settleDate)
|
||||
.Select(x => x.id).ToList();
|
||||
Console.WriteLine($"盘前手动互换资金记录数: {manualCashIds.Count}");
|
||||
|
||||
// 触发重收盘
|
||||
var service = new SwapEodPositionService(userInfo);
|
||||
service.SwapPositionCompose(settleDate, preSettleDate, new List<int>{ clientId });
|
||||
|
||||
// 盘后验证:手动互换记录必须原样保留
|
||||
var survivors = ctx.ClientCashInCashOut
|
||||
.Where(x => manualCashIds.Contains(x.id)).ToList();
|
||||
Assert.AreEqual(manualCashIds.Count, survivors.Count,
|
||||
"手动互换资金记录不应被重收盘删除");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、团队决策问题
|
||||
|
||||
1. **选哪个方案?** 推荐方案 A(最小改动+治本),但需先确认 `swap_flow_event.ClientCashId` 在自动互换路径的赋值完整性。方案 B 需数据库迁移,方案 C 适合长期。
|
||||
|
||||
2. **历史被误删的数据怎么办?** 若生产已发生"手动互换资金记录被重收盘删除",是否需要从 `clientcashincashout_history`(`YLContext.cs:127`)或备份恢复?需业务确认影响范围。
|
||||
|
||||
3. **是否补单测兜底?** 当前 Swap 模块缺少"重收盘不误删手动数据"的单测,建议补自测 3 的录制测试进回归,防止 `44e89726` 类问题复发。
|
||||
|
||||
---
|
||||
|
||||
## 附录:关键代码位置索引
|
||||
|
||||
| 项 | 文件:行号 |
|
||||
|----|----------|
|
||||
| 删除逻辑(bug 所在)| `SwapTradeBaseService.cs:374`(ClearSwapPositions),第 398-409 行 |
|
||||
| 重收盘调用入口 | `SwapEodPositionService.cs:94`(SwapPositionCompose,`removeEventTyps={自动互换}`, `delAfter=true`)|
|
||||
| 资金记录生成(手动互换)| `SwapDealService.cs:1471`(SwapIncome)/ `:1519`(ApproveSwapTrade)|
|
||||
| 资金记录生成(自动互换)| `SwapEodPositionService.cs:516`(利息腿)/ `:522`(预付金)/ `:531`(分红)|
|
||||
| `AddClientCashInCashOut`(不设来源标记)| `SwapTradeBaseService.cs:285-318` |
|
||||
| `ClientCashInCashOut` 实体(无来源字段)| `ClientCashInCashOut.cs:232` |
|
||||
| 引入 bug 的提交 | `44e89726`(2026-05-14,把 `系统操作_互换` 加进删除 Action 列表)|
|
||||
| 安全的对照基线 | `5f97909f`(2026-04-29,按 ClientCashId 精准删除)|
|
||||
Reference in New Issue
Block a user