diff --git a/YLErpDAL/Helpers/BondCalcHepler.cs b/YLErpDAL/Helpers/BondCalcHepler.cs index a62b54f1..4f7acd21 100644 --- a/YLErpDAL/Helpers/BondCalcHepler.cs +++ b/YLErpDAL/Helpers/BondCalcHepler.cs @@ -23,7 +23,8 @@ namespace YLErp.Helpers /// /// 计算器(债券净价/全价/收益率互算)。 /// 兼容旧调用方(如 RealtimePnlCalc):返回 CalBondResult,失败返回 null。 - /// targetDate 可选:估值日(yyyy-MM-dd),不传则沿用计算器代理默认 T+1。 + /// targetDate 可选:估值日(yyyy-MM-dd)。前端(fe)已在债券互换录入页强制要求 StartDate(开始日)必填、 + /// 为空则报错不调用本接口;故经 UI 的计算请求总会带上估值日,此处的"代理默认 T+1"仅兜底非 UI 调用方(如 RealtimePnlCalc)。 /// public static CalBondResult BondCalc(string underlyingCode, decimal price, string priceType = "DP", string targetDate = null) { diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index 20982548..08f6ad7b 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -45,6 +45,30 @@ describe('逐字段手动锁定:手填过的字段永不回写', () => { expect(state.cleanPrice).toBe(99); 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('边界:计算器未返回的值不覆盖、无变化不写', () => { diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 089e79ec..9dac27b6 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -395,3 +395,36 @@ describe('D3 债券计算器失败提示去重', () => { expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 新一次错误 → 重新提示 }); }); + +// ============================================================================ +// 估值日(开始日)缺失守卫:需求——债券净价/全价/收益率以【互换起始日 StartDate】估值; +// 开始日现已默认即有,故不再"悄悄回退到交易日(TradeDate)",而是真的为空时返回错误文案, +// 由 calcBondForItem 报错提示并 return(不调用计算器、不覆盖手工输入)。 +// getBondStartDateMissingMsg 为纯函数,jest 直接覆盖。 +// ============================================================================ +describe('估值日(开始日)缺失 → 报错提示、不悄悄回退交易日', () => { + test('开始日有值 → 返回 null(不报错、继续计算)', () => { + expect(SwapCalc.getBondStartDateMissingMsg('2026-07-23')).toBeNull(); + expect(SwapCalc.getBondStartDateMissingMsg('2026-01-01')).toBeNull(); + expect(SwapCalc.getBondStartDateMissingMsg('2026/07/23')).toBeNull(); + }); + + test('开始日为空/undefined/null → 返回错误文案(须提示用户)', () => { + expect(typeof SwapCalc.getBondStartDateMissingMsg('')).toBe('string'); + expect(typeof SwapCalc.getBondStartDateMissingMsg(null)).toBe('string'); + expect(typeof SwapCalc.getBondStartDateMissingMsg(undefined)).toBe('string'); + // 文案需同时引导"先填开始日"和"可手动填三数"(对应需求2) + const m = SwapCalc.getBondStartDateMissingMsg(''); + expect(m).toMatch(/开始日/); + expect(m).toMatch(/手动填写/); + }); + + test('与 D3 去重配合:开始日缺失时同一文案只弹一次', () => { + const item = {}; + const msg = SwapCalc.getBondStartDateMissingMsg(''); + // calcBondForItem 中:if (SwapCalc.shouldShowBondErr(item, msg)) main.message(msg); return; + expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(true); // 首次 → 提示 + expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(false); // 价格格逐键输入连刷 → 抑制 + expect(SwapCalc.shouldShowBondErr(item, null)).toBe(false); // 估值日补填后成功 → 清标记 + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 915d8dad..58e7cfbe 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -323,6 +323,19 @@ return true; } + /** + * 估值日(开始日)缺失校验(纯函数,jest 可测)。 + * 需求:债券净价/全价/收益率以【互换起始日 StartDate】估值;开始日现已默认即有, + * 故不再"悄悄回退到交易日",而是真的为空时返回错误文案,由上层提示用户 + * (并允许其手动填写三项数值,见需求2)。 + * 返回 非空字符串=缺失需提示;返回 null=已具备估值日。 + */ + function getBondStartDateMissingMsg(startDate) { + if (startDate) return null; + return "请先填写开始日(互换起始日,作为估值日)后再计算债券净价/全价/收益率;" + + "若暂不需要计算,可手动填写净价/全价/收益率三项数值"; + } + return { applyBondCalcResult: applyBondCalcResult, getBondCalcErrorMessage: getBondCalcErrorMessage, @@ -331,6 +344,7 @@ clearBondCalcFlags: clearBondCalcFlags, markExistingBondManual: markExistingBondManual, shouldShowBondErr: shouldShowBondErr, + getBondStartDateMissingMsg: getBondStartDateMissingMsg, 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 d1b0cbbf..5524ff19 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -298,12 +298,16 @@ const vue = new Vue({ if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示 var priceType = item.bondDriverType === 'CP' ? 'CP' : item.bondDriverType === 'DP' ? 'DP' : 'YD'; var self = this; - // 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值 - // (该字段在页面上本就是可选择的日期控件 vue-datepicker,用户可改); - // 缺省回退成交日期,再回退 null → 代理层沿用默认 T+1 - var targetDate = (this.trade && this.trade.StartDate) ? this.trade.StartDate - : (this.trade && this.trade.TradeDate) ? this.trade.TradeDate - : null; + // 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。 + // 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。 + // 不再"悄悄回退到交易日(TradeDate)"——真的为空时直接报错提示,由用户填写/手动填三数(需求2)。 + var targetDate = (this.trade && this.trade.StartDate) ? this.trade.StartDate : null; + var sdMsg = SwapCalc.getBondStartDateMissingMsg(targetDate); + if (sdMsg) { + // D3 同款去重:开始日缺失时用户在价格格逐键输入会连刷相同提示,故同一文案只弹一次 + if (SwapCalc.shouldShowBondErr(item, sdMsg)) main.message(sdMsg); + return; // 估值日缺失:不调用计算器、不覆盖任何手工输入 + } main.post('/Bond/CalcBond', { underlyingCode: item.UnderlyingCode, // 模型 item.PosiGrossPrice/PosiNetNoFeePrice/InitYtm 存的是【存储态小数】(如 0.995),