feat(swaptrade): 债券三字段计算器交互改为回车触发并加源/AUTO/REV 三态标识

- 约定1:仅在净价/全价/收益率输入框敲回车触发计算器,失焦不触发
- 约定2:计算成功->源字段标"源"、另两字段标"AUTO";失败->保留源值、清空另两字段与标识
- 约定3:编辑某字段未回车->不联动另两字段,清源/AUTO、仅本字段标"REV"
- swapCalc.js 新增 applyBondCalcSuccess/Failure/ManualEdit 纯函数,applyBondCalcResult 第三参由 manualSet 改为 driver(跳过源字段写回)
- fastVue.base.js 新增 enter 事件区分回车/失焦,向后兼容旧的 input 行为
- TradeEdit.cshtml 三字段 input/enter 分离绑定 + 源/AUTO/REV 三态标签(去除旧铅笔图标)
- 重写 bondCalc.test.js / 更新 swapCalc.test.js 覆盖状态机;jest 68 项全过
This commit is contained in:
hjhan
2026-07-29 14:34:25 +08:00
parent 8c4f452482
commit b9f2bd14f6
6 changed files with 271 additions and 197 deletions
+3 -3
View File
@@ -474,13 +474,13 @@
</a> </a>
</td> </td>
<td v-if="trade.StructureType!='普通收益互换'"> <td v-if="trade.StructureType!='普通收益互换'">
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatSwapBondDeliveryPrice" v-on:input="onDpPriceInput(item)"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='DP'">源</span><span class="glyphicon glyphicon-pencil" style="color:#ef6c00;font-size:11px;margin-left:4px;cursor:default;" v-if="item.isBond && item.bondManual && item.bondManual.DP" title="手动编辑:该字段由您填写,不会被计算器反算覆盖"></span> <vue-number-input :key="getPosiPriceFormatKey(item,'PosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatSwapBondDeliveryPrice" v-on:input="onDpPriceInput(item)" v-on:enter="onBondPriceEnter(item,'DP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='DP'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="item.isBond && item.bondAuto && item.bondAuto.DP">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondRev && item.bondRev.DP" title="人工输入:未回车触发计算器">REV</span>
</td> </td>
<td v-if="trade.StructureType!='普通收益互换'"> <td v-if="trade.StructureType!='普通收益互换'">
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiNetNoFeePrice')" v-model="item.PosiNetNoFeePrice" v-bind:format="inputFormatSwapBondNetPriceAndYtm" v-on:input="onBondPriceInput(item,'CP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='CP'">源</span><span class="glyphicon glyphicon-pencil" style="color:#ef6c00;font-size:11px;margin-left:4px;cursor:default;" v-if="item.isBond && item.bondManual && item.bondManual.CP" title="手动编辑:该字段由您填写,不会被计算器反算覆盖"></span> <vue-number-input :key="getPosiPriceFormatKey(item,'PosiNetNoFeePrice')" v-model="item.PosiNetNoFeePrice" v-bind:format="inputFormatSwapBondNetPriceAndYtm" v-on:input="onBondPriceEdit(item,'CP')" v-on:enter="onBondPriceEnter(item,'CP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='CP'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="item.isBond && item.bondAuto && item.bondAuto.CP">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondRev && item.bondRev.CP" title="人工输入:未回车触发计算器">REV</span>
</td> </td>
<td v-if="trade.StructureType!='普通收益互换'"> <td v-if="trade.StructureType!='普通收益互换'">
<vue-number-input :key="getPosiPriceFormatKey(item,'InitYtm')" v-model="item.InitYtm" v-bind:format="inputFormatSwapBondNetPriceAndYtm" v-on:input="onBondPriceInput(item,'YD')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='YD'">源</span><span class="glyphicon glyphicon-pencil" style="color:#ef6c00;font-size:11px;margin-left:4px;cursor:default;" v-if="item.isBond && item.bondManual && item.bondManual.YD" title="手动编辑:该字段由您填写,不会被计算器反算覆盖"></span><a href="javascript:void(0);" v-on:click="resetBondCalc(item)" v-if="item.isBond" style="margin-left:6px;font-size:11px;color:#1565c0;">重算</a> <vue-number-input :key="getPosiPriceFormatKey(item,'InitYtm')" v-model="item.InitYtm" v-bind:format="inputFormatSwapBondNetPriceAndYtm" v-on:input="onBondPriceEdit(item,'YD')" v-on:enter="onBondPriceEnter(item,'YD')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='YD'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="item.isBond && item.bondAuto && item.bondAuto.YD">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondRev && item.bondRev.YD" title="人工输入:未回车触发计算器">REV</span><a href="javascript:void(0);" v-on:click="resetBondCalc(item)" v-if="item.isBond" style="margin-left:6px;font-size:11px;color:#1565c0;">重算</a>
</td> </td>
<td v-if="trade.StructureType=='普通收益互换'"> <td v-if="trade.StructureType=='普通收益互换'">
<vue-number-input :key="getPosiPriceFormatKey(item,'normalPosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatSwapDeliveryPrice" v-on:input="changeSpotPrice(item)"></vue-number-input> <vue-number-input :key="getPosiPriceFormatKey(item,'normalPosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatSwapDeliveryPrice" v-on:input="changeSpotPrice(item)"></vue-number-input>
+101 -87
View File
@@ -1,126 +1,94 @@
/** /**
* bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫 * bondCalc.test.js — 债券净价/全价/收益率三字段互算driver 源 + 交互约定2/3 状态机)
* ============================================================================ * ============================================================================
* 核心保证(用户最担心的,也是第一天讨论的诉求): * 模型(交互约定1/2/3):
* 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它; * - driver(bondDriverType):回车的"源"字段(CP净价 / DP全价 / YD收益率),恒为最后回车者。
* 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖 * - bondAuto:计算器算出的另两个字段("AUTO"
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个 * - bondRev:编辑未回车的人工输入字段("REV"
* - applyBondCalcResult(state, calc, driver):回写时跳过 driver 源字段(保留用户手输值),
* 另两字段用计算器结果覆盖——即"回车某字段即以它为源反算另两个"。
* *
* 运行:cd YLErpWeb/fe-tests && npm i && npm test * 运行:cd YLErpWeb/fe-tests && npm i && npm test
* 纯逻辑在 swapCalc.js::applyBondCalcResultUMDnode 可直接 require)。 * 纯逻辑在 swapCalc.jsUMDnode 可直接 require)。
*/ */
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 };
describe('逐字段手动锁定:手填过的字段永不回写', () => { describe('以 driver 为源:回写时源字段保持、另两字段被反算覆盖', () => {
test('仅手填净价(CP):净价保持,全价/收益率被反算覆盖', () => { test('回车净价(CP)为源:净价保持,全价/收益率被反算覆盖', () => {
const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; // 用户手填净价 99.5 const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null };
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: false }); SwapCalc.applyBondCalcResult(state, CALC, 'CP');
expect(state.cleanPrice).toBe(99.5); // 手填:绝不回写 expect(state.cleanPrice).toBe(99.5); // 源:保持
expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖 expect(state.dirtyPrice).toBe(100); // 非源:被反算覆盖
expect(state.ytm).toBe(2.5); expect(state.ytm).toBe(2.5);
}); });
test('手填净价+全价(CP&DP):两者保持,仅收益率被覆盖', () => { test('回车全价(DP)为源:全价保持,净价/收益率被反算覆盖', () => {
const state = { cleanPrice: 99.5, dirtyPrice: 100.5, ytm: null }; const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null };
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: false }); SwapCalc.applyBondCalcResult(state, CALC, 'DP');
expect(state.cleanPrice).toBe(99.5); // 手填:保持 expect(state.dirtyPrice).toBe(100.5); // :保持
expect(state.dirtyPrice).toBe(100.5); // 手填:保持 expect(state.cleanPrice).toBe(99);
expect(state.ytm).toBe(2.5); // 未手填:被反算覆盖 expect(state.ytm).toBe(2.5);
}); });
test('用户不认可计算结果、手填全部三个:全部保留,无一被回写(核心场景)', () => { test('回车收益率(YD)为源:收益率保持,净价/全价被反算覆盖', () => {
const state = { cleanPrice: 98, dirtyPrice: 101, ytm: 3.0 }; // 用户全手填
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: true });
expect(state.cleanPrice).toBe(98); // 全部手填 → 全部保留
expect(state.dirtyPrice).toBe(101);
expect(state.ytm).toBe(3.0);
});
test('手填收益率(YD):收益率保持,净价/全价被覆盖', () => {
const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: false, YD: true }); SwapCalc.applyBondCalcResult(state, CALC, 'YD');
expect(state.ytm).toBe(2.6); expect(state.ytm).toBe(2.6); // 源:保持
expect(state.cleanPrice).toBe(99); expect(state.cleanPrice).toBe(99);
expect(state.dirtyPrice).toBe(100); expect(state.dirtyPrice).toBe(100);
}); });
test('仅手填全价(DP):全价保持,净价/收益率被反算覆盖(最常用场景)', () => {
const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; // 用户手填全价 100.5
SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: false });
expect(state.dirtyPrice).toBe(100.5); // 手填:绝不回写
expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖
expect(state.ytm).toBe(2.5);
});
test('手填净价+收益率(CP&YD):两者保持,仅全价被覆盖', () => {
const state = { cleanPrice: 98.5, dirtyPrice: null, ytm: 3.2 };
SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: true });
expect(state.cleanPrice).toBe(98.5); // 手填:保持
expect(state.ytm).toBe(3.2); // 手填:保持
expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖
});
test('手填全价+收益率(DP&YD):两者保持,仅净价被覆盖', () => {
const state = { cleanPrice: null, dirtyPrice: 101, ytm: 2.8 };
SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: true });
expect(state.dirtyPrice).toBe(101); // 手填:保持
expect(state.ytm).toBe(2.8); // 手填:保持
expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖
});
}); });
describe('边界:计算器未返回的值不覆盖、无变化不写', () => { describe('边界:计算器未返回的值不覆盖、无变化不写', () => {
test('手填净价 且 计算器未返收益率:源与缺失值均不写', () => { test('回车净价(源) 且 计算器未返收益率:源与缺失值均不写', () => {
const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 }; const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined }; const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined };
SwapCalc.applyBondCalcResult(state, partial, { CP: true, DP: false, YD: false }); SwapCalc.applyBondCalcResult(state, partial, 'CP');
expect(state.cleanPrice).toBe(null); // CP 手填:即便 calc 返 99 也不写 expect(state.cleanPrice).toBe(null); // CP :即便 calc 返 99 也不写
expect(state.dirtyPrice).toBe(100); // 未手填:被覆盖 expect(state.dirtyPrice).toBe(100); // 非源:被覆盖
expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值 expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值
}); });
test('派生值与当前值差异<EPS:不写(避免光标跳动)', () => { test('派生值与当前值差异<EPS:不写(避免光标跳动)', () => {
const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 }; const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 };
const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 }; const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 };
SwapCalc.applyBondCalcResult(state, near, { CP: true, DP: false, YD: false }); SwapCalc.applyBondCalcResult(state, near, 'CP');
expect(state.cleanPrice).toBe(99.5); // 手填:不写 expect(state.cleanPrice).toBe(99.5); // :不写
expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写 expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写
expect(state.ytm).toBe(2.5); expect(state.ytm).toBe(2.5);
}); });
// 模拟 calcBondForItemproxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。 // 模拟 calcBondForItemproxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。
test('浮动腿 item 写回:仅未手填字段被覆盖,且存储态转换正确', () => { test('浮动腿 item 写回:仅非源字段被覆盖,且存储态转换正确', () => {
// 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995 // 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995
const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, bondDriverType: 'CP' };
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态 const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态
const proxy = { const proxy = {
cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5手填 cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5
dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100 dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100
ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6 ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6
}; };
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); SwapCalc.applyBondCalcResult(proxy, calc, 'CP');
item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice);
item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice);
item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm);
expect(item.PosiNetNoFeePrice).toBe(0.995); // 手填(净价):存储态保持 0.995(界面仍显示 99.5) expect(item.PosiNetNoFeePrice).toBe(0.995); // (净价):存储态保持 0.995(界面仍显示 99.5)
expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0 expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0
expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025 expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025
}); });
// 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01 // 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01
test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => { test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => {
const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP' };
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 }; const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 };
const proxy = { const proxy = {
cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100 cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100
dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice),
ytm: SwapCalc.bondPriceToCalc(item.InitYtm) ytm: SwapCalc.bondPriceToCalc(item.InitYtm)
}; };
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); SwapCalc.applyBondCalcResult(proxy, calc, 'CP');
item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice);
item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice);
item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm);
@@ -135,15 +103,14 @@ describe('边界:计算器未返回的值不覆盖、无变化不写', () => {
[105, 107.3, 4.567890], [105, 107.3, 4.567890],
[100, 102.17928767123287, 4.860445236] [100, 102.17928767123287, 4.860445236]
])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => { ])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => {
const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, bondDriverType: 'CP' };
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm }; const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm };
const proxy = { const proxy = {
cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice),
dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice),
ytm: SwapCalc.bondPriceToCalc(item.InitYtm) ytm: SwapCalc.bondPriceToCalc(item.InitYtm)
}; };
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); SwapCalc.applyBondCalcResult(proxy, calc, 'CP');
item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice);
item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice);
item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm);
@@ -212,22 +179,69 @@ describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的
expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull();
}); });
test('端到端:业务错误时不覆盖手工输入(仅提示', () => { test('端到端:业务错误时保留源字段、清空另两字段与标识(交互约定2 失败', () => {
const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, bondDriverType: 'CP' };
bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } };
const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 }; const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
const err = SwapCalc.getBondCalcErrorMessage(respObj); const err = SwapCalc.getBondCalcErrorMessage(respObj);
expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并 return expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并调 applyBondCalcFailure
if (!err) { if (err) SwapCalc.applyBondCalcFailure(item, 'CP');
const proxy = { cleanPrice: item.PosiNetNoFeePrice, dirtyPrice: item.PosiGrossPrice, ytm: item.InitYtm }; expect(item.PosiNetNoFeePrice).toBe(99.5); // 源:保留用户输入
SwapCalc.applyBondCalcResult(proxy, respObj, item.bondManual); expect(item.PosiGrossPrice).toBeNull(); // 非源:清空
item.PosiNetNoFeePrice = proxy.cleanPrice; expect(item.InitYtm).toBeNull();
item.PosiGrossPrice = proxy.dirtyPrice; expect(item.bondDriverType).toBeNull(); // 三字段标识全清
item.InitYtm = proxy.ytm; expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
} expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(item.PosiNetNoFeePrice).toBe(99.5); });
expect(item.PosiGrossPrice).toBe(100.0); });
expect(item.InitYtm).toBe(2.6);
// ============================================================================
// 交互约定1/2/3 状态机:applyBondCalcSuccess / applyBondCalcFailure / applyBondManualEdit
// ============================================================================
describe('交互约定2/3 状态机:applyBondCalcSuccess / Failure / ManualEdit', () => {
test('applyBondCalcSuccess(CP):源=CPAUTO={DP,YD}REV 清空', () => {
const s = { bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: true, DP: false, YD: false } };
SwapCalc.applyBondCalcSuccess(s, 'CP');
expect(s.bondDriverType).toBe('CP');
expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true });
expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('applyBondCalcSuccess(DP):源=DPAUTO={CP,YD}', () => {
const s = {};
SwapCalc.applyBondCalcSuccess(s, 'DP');
expect(s.bondDriverType).toBe('DP');
expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true });
});
test('applyBondCalcFailure(CP):源值保留,另两字段清空,标识全清', () => {
const s = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100, InitYtm: 2.6,
bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondCalcFailure(s, 'CP');
expect(s.PosiNetNoFeePrice).toBe(99.5); // 源:保留
expect(s.PosiGrossPrice).toBeNull(); // 非源:清空
expect(s.InitYtm).toBeNull();
expect(s.bondDriverType).toBeNull();
expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(s.bondRev).toEqual({ CP: false, DP: false, YD: false });
});
test('applyBondManualEdit(DP):清 源/AUTO,仅 DP 标 REV(交互约定3', () => {
const s = { bondDriverType: 'CP', bondAuto: { CP: false, DP: true, YD: true }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondManualEdit(s, 'DP');
expect(s.bondDriverType).toBeNull();
expect(s.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(s.bondRev).toEqual({ CP: false, DP: true, YD: false }); // 仅本字段 REV
});
test('全价回车后净价再回车:净价成新源、全价降级为 AUTO(最后回车者恒为源)', () => {
const s = { PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null,
bondDriverType: null, bondAuto: { CP: false, DP: false, YD: false }, bondRev: { CP: false, DP: false, YD: false } };
SwapCalc.applyBondCalcSuccess(s, 'DP'); // 全价回车
expect(s.bondDriverType).toBe('DP');
expect(s.bondAuto).toEqual({ CP: true, DP: false, YD: true });
SwapCalc.applyBondCalcSuccess(s, 'CP'); // 净价再回车
expect(s.bondDriverType).toBe('CP');
expect(s.bondAuto).toEqual({ CP: false, DP: true, YD: true }); // 全价从源降级为 AUTO
}); });
}); });
+44 -46
View File
@@ -293,79 +293,77 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准',
}); });
// ============================================================================ // ============================================================================
// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券 // D1 回归守卫:切换债券标的必须清空 源/AUTO/REV 标志,避免旧债券状态污染新债券
// 旧 bugsetUnderlyingCode 切债券时未重置 bondManual/bondDriverType // 旧 bugsetUnderlyingCode 切债券时未重置状态,旧债券标记过的字段在新债券上会被错误沿用。
// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。
// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。 // 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。
// ============================================================================ // ============================================================================
describe('D1 切换标的清空债券互算手动/源标志', () => { describe('D1 切换标的清空债券互算源/AUTO/REV 标志', () => {
test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => { test('clearBondCalcFlags 把 bondDriverType/bondAuto/bondRev 全清', () => {
const item = { const item = {
bondManual: { CP: true, DP: false, YD: true }, bondDriverType: 'YD',
bondDriverType: 'YD' bondAuto: { CP: true, DP: true, YD: false },
bondRev: { CP: false, DP: false, YD: true }
}; };
const out = SwapCalc.clearBondCalcFlags(item); const out = SwapCalc.clearBondCalcFlags(item);
expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondDriverType).toBeNull(); expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
}); });
test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => { test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => {
const item = { UnderlyingCode: '200000.IB', isBond: true }; const item = { UnderlyingCode: '200000.IB', isBond: true };
const out = SwapCalc.clearBondCalcFlags(item); const out = SwapCalc.clearBondCalcFlags(item);
expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondDriverType).toBeNull(); expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响 expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响
}); });
test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => { test('模拟"债券A回车→切债券B":B 不应继承 A 的源/AUTO/REV', () => {
// 债券 A:用户手填了全价,标记手动 + 设源 // 债券 A:用户回车了全价,标记 + AUTO
const item = { UnderlyingCode: '190000.IB', isBond: true, const item = { UnderlyingCode: '190000.IB', isBond: true,
bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' }; bondDriverType: 'DP',
bondAuto: { CP: true, DP: false, YD: true },
bondRev: { CP: false, DP: false, YD: false } };
// 切到债券 BsetUnderlyingCode 会调 clearBondCalcFlags // 切到债券 BsetUnderlyingCode 会调 clearBondCalcFlags
SwapCalc.clearBondCalcFlags(item); SwapCalc.clearBondCalcFlags(item);
item.UnderlyingCode = '200000.IB'; item.UnderlyingCode = '200000.IB';
// 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误 expect(item.bondDriverType).toBeNull(); // B 无计算源
const manual = item.bondManual; expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志 expect(item.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源
}); });
}); });
// ============================================================================ // ============================================================================
// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志 // D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算标识随页面重置丢失 →
// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格 // 加载路径调 clearBondCalcMarksOnLoad 把 源/AUTO/REV 全部清空,纯展示保存值、无锁定
// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual // 交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格;
// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算 // 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)
// ============================================================================ // ============================================================================
describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => { describe('D2 重开已保存债券单 → 三字段无标识、编辑不联动另两格', () => {
test('markExistingBondManual 把三字段全锁 true、不置计算源', () => { test('clearBondCalcMarksOnLoad 把 源/AUTO/REV 全清、不置计算源、不动价格值', () => {
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026,
const out = SwapCalc.markExistingBondManual(item); bondDriverType: 'CP',
expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true }); bondAuto: { CP: false, DP: true, YD: true },
expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导 bondRev: { CP: false, DP: false, YD: false } };
const out = SwapCalc.clearBondCalcMarksOnLoad(item);
expect(out.bondDriverType).toBeNull();
expect(out.bondAuto).toEqual({ CP: false, DP: false, YD: false });
expect(out.bondRev).toEqual({ CP: false, DP: false, YD: false });
expect(out.PosiNetNoFeePrice).toBe(0.995); // 价格值本身不动,仅清标识
expect(out.PosiGrossPrice).toBe(1.0);
}); });
test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => { test('重开后编辑某一格(未回车):交互约定3 使另两格不被反算覆盖', () => {
const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 }; const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
SwapCalc.markExistingBondManual(item); SwapCalc.clearBondCalcMarksOnLoad(item);
// 模拟用户编辑全价(DP)onBondPriceInput 会置 bondDriverType='DP' 并重算 // 模拟用户编辑全价(DP)但未回车onBondPriceEdit 调 applyBondManualEdit → 清源/AUTO、DP 标 REV,不调计算器
item.bondDriverType = 'DP'; SwapCalc.applyBondManualEdit(item, 'DP');
const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 }; expect(item.bondDriverType).toBeNull(); // 无计算源 → 不会反算
const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 }; expect(item.bondAuto).toEqual({ CP: false, DP: false, YD: false });
SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); expect(item.bondRev).toEqual({ CP: false, DP: true, YD: false });
// 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留 expect(item.PosiNetNoFeePrice).toBe(0.995); // 另两格原值保留
expect(proxy.cleanPrice).toBe(99.5); expect(item.InitYtm).toBe(0.026);
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();
}); });
}); });
@@ -201,19 +201,20 @@
} }
/** /**
* 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。 * 债券净价/全价/收益率三字段互算:以 driver(CP/DP/YD) 为"源",回写时跳过源字段(保留用户手输值),
* - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的 * 其余两字段用计算器结果覆盖。
* - driver: 'CP'净价 / 'DP'全价 / 'YD'收益率,即本次回车的输入依据
* - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj * - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj
* - state: 持有三个字段的对象(直接原地写回) * - state: 持有三个字段的对象(直接原地写回)
* 设计(回应"计算结果不认可时如何优雅手动覆盖"): * 设计(对应交互约定1+2):
* 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。 * 回车某字段即以其为源调计算器,另两个字段被反算覆盖;源字段保持用户手输值不被回写。
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个 * 故用户回车净价→全价/收益率被重算;再次回车全价→净价/收益率被重算(最后回车者恒为源)
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。 * 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
*/ */
function applyBondCalcResult(state, calc, manualSet) { function applyBondCalcResult(state, calc, driver) {
var EPS = 1e-4; var EPS = 1e-4;
function write(field, type, value) { function write(field, type, value) {
if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写 if (driver === type) return; // 源字段:保留用户手输值,不回写
if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖 if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖
var cur = state[field]; var cur = state[field];
if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动 if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动
@@ -241,15 +242,58 @@
return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数 return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数
} }
/** /**
* 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。 * 清空债券三字段互算的【源/AUTO/REV】标志(纯函数,jest 可测)。
* 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType) * 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的状态污染新债券(D1 修复)。
* 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复) * 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象,重赋值能正常触发响应式更新
* 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual),
* 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。
*/ */
function clearBondCalcFlags(state) { function clearBondCalcFlags(state) {
state.bondManual = { CP: false, DP: false, YD: false };
state.bondDriverType = null; state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【成功】后的标识落地。
* - 以 driver(CP/DP/YD) 为"源",标记 bondDriverType=driver
* - 其余两字段标记 bondAuto=true"AUTO"=计算器算出的);
* - 清除 bondRev(人工输入标记)。
* 不写任何价格数值——数值由 applyBondCalcResult 回写。
*/
function applyBondCalcSuccess(state, driver) {
state.bondDriverType = driver;
state.bondAuto = { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【失败】后的标识与数值落地。
* - 保留用户刚回车的"源"字段值(driver),让用户可就地修改;
* - 另两个字段清空为空白("另外两个数值都更新为空白");
* - 三个数值旁边的标识全部清空("三个数值旁边都清空标识")。
*/
function applyBondCalcFailure(state, driver) {
var fields = { CP: 'PosiNetNoFeePrice', DP: 'PosiGrossPrice', YD: 'InitYtm' };
Object.keys(fields).forEach(function (t) {
if (t !== driver) state[fields[t]] = null; // 非源字段清空
});
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定3(纯函数,jest 可测):编辑某字段但未回车(失焦/输入)时的标识落地。
* - 清除 源 与 AUTO 标识(不再代表任何已算结果);
* - 仅本字段标 bondRev[type]=true"REV"=人工输入),不联动另外两字段。
*/
function applyBondManualEdit(state, type) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
if (type) state.bondRev[type] = true;
return state; return state;
} }
@@ -296,16 +340,16 @@
} }
/** /**
* D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时, * 加载路径(重开/刷新已保存债券单)调用(纯函数,jest 可测):
* 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值 * 把三字段互算的状态标识全部清空,使已保存的净价/全价/收益率纯展示、无任何 源/AUTO/REV 标记
* - 不设置 bondDriverType:重开时没有任何字段作为"计算源"calcBondForItem 会早返回(要求 driver 非空), * - 不锁定、不置源:交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格;
* 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导 * 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)
* - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true * 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券无副作用。
* 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。
* 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。
*/ */
function markExistingBondManual(state) { function clearBondCalcMarksOnLoad(state) {
state.bondManual = { CP: true, DP: true, YD: true }; state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state; return state;
} }
@@ -342,7 +386,10 @@
bondPriceToCalc: bondPriceToCalc, bondPriceToCalc: bondPriceToCalc,
bondCalcPriceToStorage: bondCalcPriceToStorage, bondCalcPriceToStorage: bondCalcPriceToStorage,
clearBondCalcFlags: clearBondCalcFlags, clearBondCalcFlags: clearBondCalcFlags,
markExistingBondManual: markExistingBondManual, applyBondCalcSuccess: applyBondCalcSuccess,
applyBondCalcFailure: applyBondCalcFailure,
applyBondManualEdit: applyBondManualEdit,
clearBondCalcMarksOnLoad: clearBondCalcMarksOnLoad,
shouldShowBondErr: shouldShowBondErr, shouldShowBondErr: shouldShowBondErr,
getBondStartDateMissingMsg: getBondStartDateMissingMsg, getBondStartDateMissingMsg: getBondStartDateMissingMsg,
roundHalfAwayFromZero: roundHalfAwayFromZero, roundHalfAwayFromZero: roundHalfAwayFromZero,
@@ -328,51 +328,61 @@ const vue = new Vue({
changeClearingAgency() { changeClearingAgency() {
this.trade.MetaDic["清算机构"] = this.viewState.Extend.ClearingAgency; this.trade.MetaDic["清算机构"] = this.viewState.Extend.ClearingAgency;
}, },
//全价(DP)列输入:先按标的单价重算名义本金(非债券标的也需要),再对债券触发三字段反算 //交互约定1+2:在净价/全价/收益率任一输入框【敲回车】→ 以该字段为源调计算器反算另两个
// 组件 vue-number-input 只 $emit('input')(失焦/回车时由底层 numberInput.onchange 触发), // 成功:源字段标"源",另两个标"AUTO"SwapCalc.applyBondCalcSuccess);
// 故此列绑 v-on:input,一个入口同时覆盖普通标的与债券两种情形 // 失败:保留源字段值、另两个清空、三字段标识全清(SwapCalc.applyBondCalcFailure
// 注意:计算触发只在回车(由 vue-number-input 的 enter 事件驱动),失焦不触发(见 onBondPriceEdit 约定3)。
onBondPriceEnter(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率
if (!item.isBond) return;
if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); };
var price = type === 'CP' ? item.PosiNetNoFeePrice
: type === 'DP' ? item.PosiGrossPrice
: item.InitYtm;
if (!hasVal(price)) return; // 空/非数:不触发计算器
this.calcBondForItem(item, type);
},
//交互约定3:编辑某字段(失焦/输入)但未回车 → 不联动另两字段,清掉 源/AUTO 标识,
// 仅本字段标"REV"(人工输入),由 SwapCalc.applyBondManualEdit 落地。
// 由 vue-number-input 的 input 事件(失焦/回车都会触发)驱动;回车时 enter 事件会随后把标识修正为 源/AUTO。
onBondPriceEdit(item, type) {
if (!item.isBond) return;
SwapCalc.applyBondManualEdit(item, type);
},
//全价列:普通标的重算名义本金;债券标的按交互约定3 标 REV(不联动)。
// 回车计算由独立的 v-on:enter="onBondPriceEnter(item,'DP')" 负责。
onDpPriceInput(item) { onDpPriceInput(item) {
this.changeSpotPrice(item); // 名义本金随全价变化(非债券标的的既有逻辑) this.changeSpotPrice(item); // 名义本金随全价变化(非债券标的的既有逻辑)
this.onBondPriceInput(item, 'DP'); // 债券:以全价为源反算净价/收益率(非债券内部会早返回) if (item.isBond) SwapCalc.applyBondManualEdit(item, 'DP'); // 交互约定3:失焦未回车→REV
}, },
//债券收益互换:编辑某一浮动腿的净价/全价/收益率之一 → 该字段标记为"已手动设过"(锁定) //放弃手动标记、以当前某字段为源重新自动反算("重算"按钮)。
// 并以它为源调计算器反算"尚未手动设过"的另两个字段(已手动设过的一律不回写)
onBondPriceInput(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率
if (!item.isBond) return;
var m = item.bondManual || { CP: false, DP: false, YD: false };
m[type] = true;
this.$set(item, 'bondManual', m); // 锁定用户手填字段
this.$set(item, 'bondDriverType', type); // 以该字段为本次计算源
this.calcBondForItem(item);
},
//放弃手动覆盖、以当前某字段为源重新自动反算(清空所有手动锁定标记)
resetBondCalc(item) { resetBondCalc(item) {
if (!item.isBond) return; if (!item.isBond) return;
this.$set(item, 'bondManual', { CP: false, DP: false, YD: false }); // 优先沿用用户最后一次回车锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误;
// 优先沿用用户最后一次编辑锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误;
// 无历史源时再按 净价→全价→收益率 回退挑一个有值的字段。 // 无历史源时再按 净价→全价→收益率 回退挑一个有值的字段。
var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); }; var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); };
var driver = item.bondDriverType var driver = item.bondDriverType
|| (hasVal(item.PosiNetNoFeePrice) ? 'CP' || (hasVal(item.PosiNetNoFeePrice) ? 'CP'
: hasVal(item.PosiGrossPrice) ? 'DP' : hasVal(item.PosiGrossPrice) ? 'DP'
: hasVal(item.InitYtm) ? 'YD' : null); : hasVal(item.InitYtm) ? 'YD' : null);
this.$set(item, 'bondDriverType', driver); if (!driver) return;
this.calcBondForItem(item); this.calcBondForItem(item, driver);
}, },
//以该浮动腿的 bondDriverType 为源调 /Bond/CalcBond;回写时跳过"已手动设过的字段"(bondManual) //以 driver(回车字段) 为源调 /Bond/CalcBond;回写时跳过源字段(保留用户手输值)、覆盖另两个。
//仅反向填充用户尚未手动设过的字段——故用户可逐个手填全部三个而互算不会冲掉任一个
//错误处理(对齐 bond-oms-ui / zszq-bond-oms): //错误处理(对齐 bond-oms-ui / zszq-bond-oms):
// - 业务/参数错误(success=false 或 errCode!=0,如债券不存在/信息不全/参数非法) // - 业务/参数错误(success=false 或 errCode!=0,如债券不存在/信息不全/参数非法)
// 以非阻塞 toast(main.message) 反馈真实原因,绝不回写、不弹模态框、不阻止手工输入; // 以非阻塞 toast(main.message) 反馈真实原因,绝不回写、不弹模态框、不阻止手工输入;
// - 网络/服务异常:框架 main.post 已弹"请求失败",此处不再二次提示。 // - 网络/服务异常:框架 main.post 已弹"请求失败",此处不再二次提示。
calcBondForItem(item) { calcBondForItem(item, explicitDriver) {
if (!item.isBond || !item.bondDriverType) return; var driver = explicitDriver || item.bondDriverType;
if (!item.isBond || !driver) return;
item.bondDriverType = driver; // 先定源,回写时 applyBondCalcResult 会跳过该字段
if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; } if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var price = item.bondDriverType === 'CP' ? item.PosiNetNoFeePrice var price = driver === 'CP' ? item.PosiNetNoFeePrice
: item.bondDriverType === 'DP' ? item.PosiGrossPrice : driver === 'DP' ? item.PosiGrossPrice
: item.InitYtm; : item.InitYtm;
if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示 if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示
var priceType = item.bondDriverType === 'CP' ? 'CP' : item.bondDriverType === 'DP' ? 'DP' : 'YD'; var priceType = driver === 'CP' ? 'CP' : driver === 'DP' ? 'DP' : 'YD';
var self = this; var self = this;
// 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。 // 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。
// 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。 // 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。
@@ -395,32 +405,33 @@ const vue = new Vue({
targetDate: targetDate targetDate: targetDate
}, { alertFn: main.message }).done(function (resp) { }, { alertFn: main.message }).done(function (resp) {
if (!resp || !resp.obj) return; if (!resp || !resp.obj) return;
// 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入 // 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不清空手工输入
var err = SwapCalc.getBondCalcErrorMessage(resp.obj); var err = SwapCalc.getBondCalcErrorMessage(resp.obj);
if (err) { if (err) {
// D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示; // D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示;
// 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。 // 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。
if (SwapCalc.shouldShowBondErr(item, err)) main.message(err); if (SwapCalc.shouldShowBondErr(item, err)) main.message(err);
SwapCalc.applyBondCalcFailure(item, driver); // 交互约定2:失败→清另两字段+标识
return; return;
} }
item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示 item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示
// 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。 // 以既有三字段为代理,调用纯函数(跳过源字段),再写回。
// 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。 // 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。
// 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100) // 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100)
// 若直接用存储态初始化,则用户手填字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态, // 若直接用存储态初始化,则字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态,
// 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。 // 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。
var proxy = { var proxy = {
cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice),
dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice),
ytm: SwapCalc.bondPriceToCalc(item.InitYtm) ytm: SwapCalc.bondPriceToCalc(item.InitYtm)
}; };
var manual = item.bondManual || { CP: false, DP: false, YD: false }; SwapCalc.applyBondCalcResult(proxy, resp.obj, driver);
SwapCalc.applyBondCalcResult(proxy, resp.obj, manual);
// 回写前 bondCalcPriceToStorage(÷100)(展示态→存储态小数):proxy 里均为展示态; // 回写前 bondCalcPriceToStorage(÷100)(展示态→存储态小数):proxy 里均为展示态;
// 模型字段存存储态(0.995),须 ÷100 落回模型,否则配合 percent:true 显示会 ×100 成离谱值。 // 模型字段存存储态(0.995),须 ÷100 落回模型,否则配合 percent:true 显示会 ×100 成离谱值。
item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice);
item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice);
item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm);
SwapCalc.applyBondCalcSuccess(item, driver); // 交互约定2:成功→标 源/AUTO
// 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后, // 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后,
// 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。 // 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。
if (self.calcNotional) self.calcNotional(); if (self.calcNotional) self.calcNotional();
@@ -1517,14 +1528,13 @@ const vue = new Vue({
arr[num].index = num; arr[num].index = num;
arr[num].PosiFeeType = thisObj.normalizePosiFeeType(arr[num].PosiFeeType); arr[num].PosiFeeType = thisObj.normalizePosiFeeType(arr[num].PosiFeeType);
this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice; this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice;
// D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失; // 加载(重开/刷新)已保存的债券成交单三字段互算状态标识全部清空,纯展示保存值、无任何 源/AUTO/REV 标记。
// 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格 // 交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格
// 故在加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,直接把三格标为手动锁定, // 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。
// 计算器在重开期间不自动推导;用户点"重算"才清除并重新计算。
var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); }; var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); };
var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType); var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType);
if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) { if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) {
thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true }); SwapCalc.clearBondCalcMarksOnLoad(arr[num]);
} }
}); });
if (thisObj.paySwapList.length > 0) { if (thisObj.paySwapList.length > 0) {
@@ -332,6 +332,7 @@
if (!event) return false; if (!event) return false;
_chnInput = -1; _chnInput = -1;
this._enterFired = false; // 每次按键先清 Enter 标记;仅在回车分支重新置位,避免误触发
let charCode = event.which || event.keyCode; let charCode = event.which || event.keyCode;
@@ -365,6 +366,7 @@
return _options.precision > 0 && !event.shiftKey && checkDotInput.call(this); return _options.precision > 0 && !event.shiftKey && checkDotInput.call(this);
case 13: //enter case 13: //enter
case 108: //enter case 108: //enter
this._enterFired = true; // 标记回车触发,供 __change 区分"回车"与"失焦"
$(this).blur(); $(this).blur();
return true; return true;
case 67: //ctrl+c case 67: //ctrl+c
@@ -469,7 +471,9 @@
else if (_options.append === '‱') f /= 10000; else if (_options.append === '‱') f /= 10000;
} }
_options.onchange && _options.onchange(f, this.value); let isEnter = !!this._enterFired;
this._enterFired = false;
_options.onchange && _options.onchange(f, this.value, isEnter);
} }
//功能禁用 //功能禁用
@@ -543,9 +547,10 @@
this.inputctrl.setValue(this.value) this.inputctrl.setValue(this.value)
}, },
methods: { methods: {
onchange(value, text) { onchange(value, text, isEnter) {
this.init = false; this.init = false;
this.$emit('input', this.ret_type === 1 ? text : value); this.$emit('input', this.ret_type === 1 ? text : value);
if (isEnter) this.$emit('enter', this.ret_type === 1 ? text : value);
} }
}, },
watch: { watch: {