/**
|
* 金额计算工具
|
*
|
* 背景:本系统金额一律以「元」为单位、保留 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
|
}
|