From b3c2fdfe38236c5daef611b987b21260fa3659d5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 7 Jul 2026 13:26:32 +0800 Subject: [PATCH] =?UTF-8?q?test(fe):=20=E8=A1=A5=E5=85=85=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=9E=B6=E6=9E=84=E9=97=B8?= =?UTF-8?q?=E9=97=A8=EF=BC=8C=E9=98=B2=E6=96=B0=E5=A2=9E=E5=86=85=E8=81=94?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生产接线处补注释,追溯到具体历史 bug (dcf649f2/20ea93d8/3c5f25a5/f873239a) - swapCalc.js 注明哪些函数已接入生产、哪些是纯交叉校验规格 - 新增 guard_arch.js 架构闸门:扫描提交新增行,拦截 Vue 组件内联金额计算 - pre-commit hook 串行运行 单元测试 + 架构闸门 --- YLErpWeb/fe-tests/guard_arch.js | 122 ++++++++++++++++++ .../Scripts/app/swaptrade/incomeSwapTrade.js | 6 + .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 12 +- .../Scripts/app/swaptrade/swapTradeEdit.js | 1 + 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 YLErpWeb/fe-tests/guard_arch.js diff --git a/YLErpWeb/fe-tests/guard_arch.js b/YLErpWeb/fe-tests/guard_arch.js new file mode 100644 index 00000000..d480b6ab --- /dev/null +++ b/YLErpWeb/fe-tests/guard_arch.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * guard_arch.js — 前端架构闸门(零依赖,纯 Node) + * ============================================================================ + * 目的:防止在 Vue 组件方法里【新增】手写「金额 / 精度」计算(toFixed / _.round / + * Math.round / × multiplier 缩放等),避免又长出一份「不可测的内联公式」。 + * + * 规则:仅扫描【本次提交改动中新增/修改的行】(git diff 的 + 行),且位于 + * wwwroot/Scripts/app/ 并定义 new Vue(...) 的文件。对其中每一行「金额算术」, + * 若该行没有路由到某个 *Calc 模块(含 SwapCalc.),则判为违规 → 退出码 1。 + * + * 设计要点: + * - 只查「新增/修改的行」,不查历史存量。这样不会阻断对存量文件(如仍含内联计算的 + * unwindSwapTrade.js)的正常改动,只拦「新写的内联金额公式」。 + * - 已外置计算逻辑的组件(incomeSwapTrade.js / swapTradeEdit.js)通过调用 SwapCalc.* + * 保持合规;新代码必须把金额计算放进 *Calc 模块(参考 swapCalc.js)。 + * - 注释行会被剥离后再判断,避免注释里的样例文字误报。 + * - 无需 jest / npm install,和 _shim_run.js 同属零依赖守卫。 + * + * 用法:node guard_arch.js (一般在 pre-commit / CI 中自动调用) + * ============================================================================ + */ +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// fe-tests -> YLErpWeb -> zszq-trs(仓库根) +const ROOT = path.resolve(__dirname, '..', '..'); + +// 金额 / 精度算术模式(命中即怀疑) +const MONEY_PATTERNS = [ + /\.toFixed\s*\(/, // 显示/入库精度 + /_\.round\s*\(/, // lodash 四舍五入 + /Math\.round\s*\(/, // 原生四舍五入 + /\*\s*this\.multiplier/, // 价格 × 乘数缩放(dcf649f2 / 20ea93d8 类) + /\*\s*thisObj\.multiplier/, +]; + +// 合规标记:行内引用了某个 *Calc 模块(如 SwapCalc.),视为已外置 +const COMPLIANT = /Calc\./; + +function tryCmd(cmd) { + try { + return execSync(cmd, { cwd: ROOT }).toString().trim(); + } catch (e) { + return ''; + } +} + +function changedAppVueFiles() { + const listCmd = 'git diff --cached --name-only --diff-filter=ACM -- "*.js"'; + let out = tryCmd(listCmd); + if (!out) out = tryCmd('git diff --name-only --diff-filter=ACM -- "*.js"'); + if (!out) return []; + return out.split('\n').filter((f) => { + if (!/wwwroot[\\/]Scripts[\\/]app[\\/]/.test(f)) return false; + const full = path.join(ROOT, f); + if (!fs.existsSync(full)) return false; + try { + return /new\s+Vue\s*\(/.test(fs.readFileSync(full, 'utf8')); + } catch (e) { + return false; + } + }); +} + +// 取文件在本次提交中【新增/修改】的行(git diff 的 + 行,排除 +++ 文件头) +function addedLines(file) { + const quoted = JSON.stringify(file); + let out = tryCmd(`git diff --cached -U0 -- ${quoted}`); + if (!out) out = tryCmd(`git diff -U0 -- ${quoted}`); + if (!out) return []; + return out.split('\n') + .filter((l) => l.startsWith('+') && !l.startsWith('+++')) + .map((l) => l.slice(1)); +} + +function stripComment(line) { + // 去掉 /* */ 块注释与 // 行内注释,避免注释里的样例文字误报 + return line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '').trim(); +} + +function main() { + const files = changedAppVueFiles(); + if (files.length) { + console.log(`[guard_arch] 扫描 ${files.length} 个改动的 Vue 组件文件的新增/修改行`); + } + + let violations = 0; + for (const file of files) { + const added = addedLines(file); + const found = []; + added.forEach((rawLine) => { + const code = stripComment(rawLine); + if (!code) return; + const hasMoney = MONEY_PATTERNS.some((p) => p.test(code)); + if (hasMoney && !COMPLIANT.test(code)) { + found.push(` + ${rawLine.trim()}`); + } + }); + + if (found.length) { + violations += found.length; + console.log(`[guard_arch] 违规 ${file} 发现【新增】内联金额计算(应路由到 *Calc 模块):`); + found.forEach((f) => console.log(f)); + } + } + + if (violations > 0) { + console.log( + `\n[guard_arch] 发现 ${violations} 处【新增】内联金额计算, 提交被阻断。` + + `请把金额/精度计算抽到 *Calc 模块(参考 swapCalc.js)。` + ); + process.exit(1); + } + console.log('[guard_arch] OK: 改动的 Vue 组件未新增内联金额计算。'); + process.exit(0); +} + +main(); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index a9681684..568f2ecd 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -72,6 +72,8 @@ const vue = new Vue({ $(this.$refs.incomeValueDatePicker.$el).val(MaxIncomeValueDate); return false; }, + // 守卫: 价格缩放因子(债券 multiplier=100 时界面为百分比态, 计算用相对价需 ÷100) + // 计算已外置到 swapCalc.getPriceScale; 改动需同步 swapCalc.test.js getPriceScale() { return SwapCalc.getPriceScale(this.multiplier); }, @@ -84,6 +86,8 @@ const vue = new Vue({ this.initPosiGrossPrice = this.floatPosition.PosiGrossPrice; // 互换标的价格固定为期初净价,与平仓不同不需要用户填写 // 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致 + // 守卫: 期末全价必须用 PosiGrossPrice(全价) 且债券 ×100 转界面态 + // 对应历史 bug dcf649f2(错用净价) / 20ea93d8(×100 缩放丢失); 外置到 swapCalc.deriveTradingAmountAvg this.floatPosition.TradingAmountAvg = SwapCalc.deriveTradingAmountAvg(this.initPosiGrossPrice, this.multiplier); this.interestList = model.FlowEvents.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; @@ -176,6 +180,8 @@ const vue = new Vue({ //thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 + // 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留) + // 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游 thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2); thisObj.calcCloseAmount(); }, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 35670d9b..d9f6edbd 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -7,6 +7,11 @@ * - Node: module.exports(UMD 包装),供 fe-tests/*.test.js 使用。 * - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致,是前后端同一份金标准。 * + * 生产接线状态(tested == used):incomeSwapTrade.js / swapTradeEdit.js 已调用 + * getPriceScale / deriveTradingAmountAvg / calcFloatPnlSum / calcStockEqvNotional + * 这 4 个叶子函数(对应真实出过的 4 个 bug:20ea93d8 / dcf649f2 / 3c5f25a5 / f873239a)。 + * calcUnwind / calcIncome 仅用于 swapCalc.test.js 的前后端金标准交叉校验,未接入生产代码。 + * * 守卫的 bug(见 git 历史): * - 20ea93d8 / dcf649f2:deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100 * - 3c5f25a5:calcFloatPnlSum 必须 .toFixed(2)(保留 2 位小数) @@ -69,7 +74,12 @@ return roundHalfAwayFromZero(step, 2); } - // ---- 组合函数:对齐 C# CalcUnwind / CalcIncome,作为前端与后端金标准的交叉校验 ---- + // ---- 组合函数:对齐 C# FrontendCalcReference.CalcUnwind / CalcIncome ---- + // 用途:作为「前端 JS 完整盈亏聚合公式」与「后端 C# 金标准」的交叉校验 + // (见 swapCalc.test.js 的 FC_001~FC_008 八个冻结场景)。 + // 注意:以下 calcUnwind / calcIncome **未接入生产代码**——生产 Vue 组件只调用上方 + // 4 个叶子函数。它们是冻结完整聚合逻辑的参考规格;若要让生产聚合逻辑也被自动守卫, + // 需把 incomeSwapTrade.js / swapTradeEdit.js / unwindSwapTrade.js 的聚合计算也改调它们。 function parseOrZero(s) { return (s === undefined || s === null || s === '') ? 0 : Number(s); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index e19f89d1..dc726ce2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -357,6 +357,7 @@ const vue = new Vue({ this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem); } var national = payItem.PosiQuantity * payItem.ContractSize; + // 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional;