| | |
| | | /** |
| | | * 金额计算工具 |
| | | * |
| | | * 背景:本系统金额一律以「元」为单位、保留 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)) { ... } |
| | | * 金额展示格式化: |
| | | * - null/undefined/空串保持空; |
| | | * - 0 显示 0.00; |
| | | * - 数字统一两位小数; |
| | | * - 非数字原样返回,避免把异常数据吞掉。 |
| | | */ |
| | | 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) |
| | | export function formatMoneyValue(value) { |
| | | if (value === null || value === undefined || value === "") { |
| | | return ""; |
| | | } |
| | | if (value instanceof Decimal) { |
| | | return value |
| | | const num = Number(value); |
| | | if (!Number.isFinite(num)) { |
| | | return value; |
| | | } |
| | | let d |
| | | try { |
| | | d = new Decimal(value) |
| | | } catch (e) { |
| | | return new Decimal(0) |
| | | } |
| | | return d.isFinite() ? d : new Decimal(0) |
| | | return num.toFixed(2); |
| | | } |
| | | |
| | | /** |
| | | * 判断是否为有效金额(可参与计算)。用于原本 !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 |
| | | /** el-table-column formatter 签名:(row, column, cellValue, index) */ |
| | | export function moneyColumnFormatter(row, column, cellValue) { |
| | | return formatMoneyValue(cellValue); |
| | | } |