diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 71c4993e..089e79ec 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -291,3 +291,107 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红'); }); }); + +// ============================================================================ +// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券 +// 旧 bug:setUnderlyingCode 切债券时未重置 bondManual/bondDriverType, +// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。 +// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。 +// ============================================================================ +describe('D1 切换标的清空债券互算手动/源标志', () => { + test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => { + const item = { + bondManual: { CP: true, DP: false, YD: true }, + bondDriverType: 'YD' + }; + const out = SwapCalc.clearBondCalcFlags(item); + expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondDriverType).toBeNull(); + }); + + test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => { + const item = { UnderlyingCode: '200000.IB', isBond: true }; + const out = SwapCalc.clearBondCalcFlags(item); + expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false }); + expect(out.bondDriverType).toBeNull(); + expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响 + }); + + test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => { + // 债券 A:用户手填了全价,标记手动 + 设源 + const item = { UnderlyingCode: '190000.IB', isBond: true, + bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' }; + // 切到债券 B(setUnderlyingCode 会调 clearBondCalcFlags) + SwapCalc.clearBondCalcFlags(item); + item.UnderlyingCode = '200000.IB'; + // 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误 + const manual = item.bondManual; + expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志 + expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源 + }); +}); + +// ============================================================================ +// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志 +// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 +// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual +// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算。 +// ============================================================================ +describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => { + test('markExistingBondManual 把三字段全锁 true、不置计算源', () => { + const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; + const out = SwapCalc.markExistingBondManual(item); + expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true }); + expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导 + }); + + test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => { + const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; + SwapCalc.markExistingBondManual(item); + // 模拟用户编辑全价(DP):onBondPriceInput 会置 bondDriverType='DP' 并重算 + item.bondDriverType = 'DP'; + const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 }; + const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + // 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留 + expect(proxy.cleanPrice).toBe(99.5); + expect(proxy.dirtyPrice).toBe(100.0); + expect(proxy.ytm).toBe(6.37); + }); + + test('加载路径不会误锁"全新未填的债券"(三值不全)', () => { + // 全新债券标的,价格字段空 → 不应被标记为手动锁定 + const item = { isBond: true, PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null }; + SwapCalc.markExistingBondManual(item); + // 全新标的本不应调用 markExistingBondManual;此处断言:即便误调也不应制造假锁定干扰后续交互 + // (实际加载逻辑用 hasV 三值齐全判定,只在已保存单上调用,此用例验证函数本身不副作用其它字段) + expect(item.UnderlyingCode).toBeUndefined(); + }); +}); + +// ============================================================================ +// D3 回归守卫:不可算债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast, +// 会连刷相同提示。修复:shouldShowBondErr 按"同一错误文案连续出现只提示一次"抑制噪声。 +// ============================================================================ +describe('D3 债券计算器失败提示去重', () => { + test('同一错误连续出现只提示一次', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 首次 → 提示 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 连刷 → 抑制 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 再刷 → 抑制 + }); + + test('错误文案变化仍照常提示', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); + expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(true); // 不同错误 → 提示 + expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(false); // 同错误 → 抑制 + }); + + test('成功(传入 null/空)清标记,下次不同错误仍能提示', () => { + const s = {}; + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); + expect(SwapCalc.shouldShowBondErr(s, null)).toBe(false); // 成功清标记,不提示 + expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 新一次错误 → 重新提示 + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 97b968a8..915d8dad 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -240,6 +240,18 @@ function bondCalcPriceToStorage(displayPrice) { return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数 } + /** + * 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。 + * 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType) + * 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复)。 + * 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual), + * 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。 + */ + function clearBondCalcFlags(state) { + state.bondManual = { CP: false, DP: false, YD: false }; + state.bondDriverType = null; + return state; + } /** * 从 /Bond/CalcBond 响应(resp.obj,即 CalBondResult)中提取应展示给用户的错误文案; @@ -283,11 +295,42 @@ return null; } + /** + * D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时, + * 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值。 + * - 不设置 bondDriverType:重开时没有任何字段作为"计算源",calcBondForItem 会早返回(要求 driver 非空), + * 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导。 + * - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true, + * 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。 + * 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。 + */ + function markExistingBondManual(state) { + state.bondManual = { CP: true, DP: true, YD: true }; + return state; + } + + /** + * D3 修复(纯函数,jest 可测):债券计算器失败提示去重。 + * 不可算的债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast,会连刷数条相同提示。 + * 这里按"同一错误文案连续出现只提示一次"抑制噪声;错误文案变化(如 债券不存在→信息不全)则照常提示, + * 成功(传入 null/空)时清掉标记,便于下次真出不同错误时仍能提示。 + * 纯做"是否该弹"的决策并维护 state._lastBondErr,不触碰任何计算逻辑,零风险。 + */ + function shouldShowBondErr(state, err) { + if (!err) { state._lastBondErr = null; return false; } // 成功/无错误:清标记、不提示 + if (state._lastBondErr === err) return false; // 连续相同错误 → 抑制重复 toast + state._lastBondErr = err; + return true; + } + return { applyBondCalcResult: applyBondCalcResult, getBondCalcErrorMessage: getBondCalcErrorMessage, bondPriceToCalc: bondPriceToCalc, bondCalcPriceToStorage: bondCalcPriceToStorage, + clearBondCalcFlags: clearBondCalcFlags, + markExistingBondManual: markExistingBondManual, + shouldShowBondErr: shouldShowBondErr, roundHalfAwayFromZero: roundHalfAwayFromZero, getPriceScale: getPriceScale, deriveTradingAmountAvg: deriveTradingAmountAvg, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 9e35ef44..d1b0cbbf 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -317,7 +317,13 @@ const vue = new Vue({ if (!resp || !resp.obj) return; // 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入 var err = SwapCalc.getBondCalcErrorMessage(resp.obj); - if (err) { main.message(err); return; } + if (err) { + // D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示; + // 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。 + if (SwapCalc.shouldShowBondErr(item, err)) main.message(err); + return; + } + item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示 // 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。 // 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。 // 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100); @@ -680,6 +686,8 @@ const vue = new Vue({ this.trade.UnderlyingCode = underlyingCode; // 债券标的:标记该浮动腿可启用净价/全价/收益率互算 this.$set(item, 'isBond', tradeHelper.IsBond(data.InstrumentType)); + // 切换标的时清空债券三字段互算的手动/源标志(D1 修复:避免旧债券手填状态污染新债券) + SwapCalc.clearBondCalcFlags(item); //this.initMarginRate(); }, setFloatRateUnderlyingCode(data, item) { @@ -1410,6 +1418,15 @@ const vue = new Vue({ thisObj.paySwapList.forEach((val, num, arr) => { arr[num].index = num; this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice; + // D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失; + // 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。 + // 故在加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,直接把三格标为手动锁定, + // 计算器在重开期间不自动推导;用户点"重算"才清除并重新计算。 + var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); }; + var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType); + if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) { + thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true }); + } }); }