yxh
4 天以前 531d433ab95e1c5ca48637eca9dc157380f675c0
src/utils/money.js
@@ -1,240 +1,22 @@
/**
 * 金额计算工具
 *
 * 背景:本系统金额一律以「元」为单位、保留 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);
}