/** * 金额计算工具 * * 背景:本系统金额一律以「元」为单位、保留 2 位小数,属财务数据。 * JS 的 Number 是 IEEE-754 双精度浮点,直接做加减乘会引入二进制舍入误差, * 典型表现: * 0.1 + 0.2 -> 0.30000000000000004 * 1.1 * 3 -> 3.3000000000000003 * 0.3 - 0.2 - 0.1 -> -2.7755575615628914e-17 (退费/扣税相减后残留尾差) * 这类尾差导致「合计对不上」「金额显示为 -0」「相等判断失败」等线上问题, * 且会随单据流转被写入数据库,事后难以追溯。 * * 因此:所有金额运算统一走本模块,禁止直接对金额使用 + - * / 与 parseFloat 累加。 * 内部基于 decimal.js(package.json 已引入),保证十进制精确运算。 * * 用法示例: * import { moneySum, moneyMul, moneySub, moneyRound, moneyEq } from "@/utils/money"; * * // 累加明细(过滤非数字与空值,不会把 NaN 传染给合计) * const total = moneySum(this.rbDetails, "amount"); * * // 单价 × 数量 * const preTax = moneyMul(row.price, row.quantity); * * // 税前 - 扣税 = 税后 * const afterTax = moneySub(row.amount, row.taxamount); * * // 落库/展示前统一保留 2 位(四舍五入) * this.form.amountrequested = moneyRound(total); * * // 金额比较(勿用 == / === 或 parseFloat 后相减) * if (!moneyEq(sumOfPayees, this.form.amountrequested)) { ... } */ import Decimal from 'decimal.js' /** 金额保留小数位 */ export const MONEY_SCALE = 2 /** * 转为 Decimal;无法识别的值(空串/undefined/null/NaN/非数字)一律按 0 处理。 * 按 0 处理而非抛错,是为了保持既有页面「!isNaN(parseFloat(x)) 才累加」的行为, * 同时避免 NaN 污染整条合计链。 * * @param {string|number|Decimal|null|undefined} value * @returns {Decimal} */ export function toMoney(value) { if (value === null || value === undefined || value === '') { return new Decimal(0) } if (value instanceof Decimal) { return value } let d try { d = new Decimal(value) } catch (e) { return new Decimal(0) } return d.isFinite() ? d : new Decimal(0) } /** * 判断是否为有效金额(可参与计算)。用于原本 !isNaN(parseFloat(x)) 的校验场景。 * * @param {*} value * @returns {boolean} */ export function isValidMoney(value) { if (value === null || value === undefined || value === '') { return false } if (value instanceof Decimal) { return value.isFinite() } if (typeof value === 'number') { return Number.isFinite(value) } const s = String(value).trim() if (s === '') { return false } try { return new Decimal(s).isFinite() } catch (e) { return false } } /** * 累加求和。 * * @param {Array} list 待累加的数组 * @param {string|Function} [selector] 取金额的方式: * 字符串 -> 取该项的该属性名,如 "amount"; * 函数 -> 用返回值,如 (item) => item.detail && item.detail.amount; * 省略 -> 直接累加数组元素本身。 * @returns {number} 求和结果(Number,便于赋值给表单字段) */ export function moneySum(list, selector) { if (!Array.isArray(list) || list.length === 0) { return 0 } let acc = new Decimal(0) for (let i = 0; i < list.length; i++) { const raw = selector === undefined ? list[i] : typeof selector === 'function' ? selector(list[i]) : list[i][selector] acc = acc.plus(toMoney(raw)) } return acc.toNumber() } /** * 求和后保留 2 位小数(优先在展示/落库时使用)。 */ export function moneySumRound(list, selector) { return roundNumber(moneySum(list, selector)) } /** * 精确加法。支持多参数或数组:moneyAdd(1.1, 2.2) / moneyAdd([1.1, 2.2]) */ export function moneyAdd(...args) { const list = args.length === 1 && Array.isArray(args[0]) ? args[0] : args let acc = new Decimal(0) list.forEach((v) => { acc = acc.plus(toMoney(v)) }) return acc.toNumber() } /** * 精确减法 a - b(可传多个:a - b - c) */ export function moneySub(a, ...rest) { let acc = toMoney(a) rest.forEach((v) => { acc = acc.minus(toMoney(v)) }) return acc.toNumber() } /** * 精确乘法(单价 × 数量、金额 × 税率) */ export function moneyMul(a, ...rest) { let acc = toMoney(a) rest.forEach((v) => { acc = acc.times(toMoney(v)) }) return acc.toNumber() } /** * 精确除法(金额 ÷ 数量),divideBy 为 0 时返回 0,不产生 Infinity */ export function moneyDiv(a, b) { const divisor = toMoney(b) if (divisor.isZero()) { return 0 } return toMoney(a).div(divisor).toNumber() } /** * 金额是否相等(十进制精确比较,不受浮点尾差影响)。 * 会自动归一化到 2 位,避免 100 与 100.001 这类边界误判。 * * @returns {boolean} */ export function moneyEq(a, b) { return roundDecimal(a).eq(roundDecimal(b)) } /** * 金额大小比较:a > b 返回 1,a < b 返回 -1,相等返回 0 */ export function moneyCmp(a, b) { return roundDecimal(a).cmp(roundDecimal(b)) } /** * 保留 2 位小数并返回 Number(四舍五入,ROUND_HALF_UP)。 * 用于赋值给表单字段、参与后端交互。 * * @param {*} value * @param {number} [scale=2] * @returns {number} */ export function moneyRound(value, scale = MONEY_SCALE) { return roundDecimal(value, scale).toNumber() } /** * 保留 2 位小数并返回字符串(用于金额展示,避免大额时出现科学计数法)。 * * @param {*} value * @param {number} [scale=2] * @returns {string} */ export function moneyText(value, scale = MONEY_SCALE) { return roundDecimal(value, scale).toFixed(scale) } /** * 保留 2 位小数并返回 Decimal,供后续继续精确运算 */ export function moneyDecimal(value, scale = MONEY_SCALE) { return roundDecimal(value, scale) } function roundDecimal(value, scale = MONEY_SCALE) { return toMoney(value).toDecimalPlaces(scale, Decimal.ROUND_HALF_UP) } // moneySum 内部已返回 Number,这里统一走同一套四舍五入 function roundNumber(value) { return roundDecimal(value).toNumber() } export default { MONEY_SCALE, toMoney, isValidMoney, moneySum, moneySumRound, moneyAdd, moneySub, moneyMul, moneyDiv, moneyEq, moneyCmp, moneyRound, moneyText, moneyDecimal }