yxh
2026-10-13 1511a7f94b1e3a88b4a54234ec144655a91eace7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
/**
 * 金额计算工具
 *
 * 背景:本系统金额一律以「元」为单位、保留 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
}