数字转文字首先是一种格式化操作,而不是安全控制。文字金额有助于人工核对,也可能是某类表单的格式要求,但它不能替代授权、签名、审计日志、防篡改存储,或银行、法院、税务机关和合同适用的具体规则。输出还会受到语言、地区、货币、文书类型和原始精度影响。

核心要点

  • 写代码前先定义契约:语言、地区、基数词或序数词、分节方式、货币、最小货币单位和舍入策略。
  • 金额优先使用最小货币单位的整数或精确十进制值,不要通过二进制浮点数相减来计算角分。
  • 英式和美式英语在 and、标点和支票格式上存在差异;不能把一种写法当成全球通用规则。
  • 普通中文数字与中文财务大写(大写金额)不是同一种表示。机构文书必须以适用的现行规范为准。
  • 对格式错误、含义不明确、超出范围或需要隐式舍入的输入,应拒绝处理,而不是生成看似合理的文本。

数字转文字到底做什么

转换器应把经过校验的数值映射为特定语言的字符串,不应擅自推断货币、改变数值或判断文书是否具有法律效力。生产系统至少应记录:

契约字段 示例
输入表示 123456 个最小货币单位
语言与地区 zh-CNen-USen-GB
数字类型 基数词、序数词或金额
货币与最小单位 USD,2 位小数
舍入策略 拒绝、银行家舍入或半入
输出策略 连字符、and、首字母大写、票据后缀

这一区分对 JSON、数据库、电子表格和外部 API 尤其重要。JSON 没有原生的任意精度整数类型,JavaScript Number 也不能精确表示所有大于 2^53 - 1 的整数。转换前应保留并校验原始表示。

中文数字规则

普通数字与财务大写

普通中文使用“一、二、十”等字;财务大写使用“壹、贰、拾”等不易混淆的字形。财务大写是文书约定,不是密码学意义上的防篡改机制。

阿拉伯数字 普通写法 财务大写
0
1
2
3
4
5
6
7
8
9
10 / 100 / 1000 十 / 百 / 千 拾 / 佰 / 仟

中文大数通常按四位分节(万、亿),而不是英文的三位分节。内部有意义的零才需要写出,具体位置取决于分组结构。实现至少要测试 10,001100,0101,010,001,不能只测试整百数字。

人民币金额

常见的人民币财务大写中,¥1,234.56 可以写作“人民币壹仟贰佰叁拾肆元伍角陆分”,整数金额可以写作“人民币壹佰元整”。是否使用“元”或“圆”、是否以“整”或“正”结尾、是否必须加“人民币”前缀,以及空位填写方式,都可能取决于文书和机构。没有适用规范的来源时,不要声称某种形式是所有发票、支票或中国人民银行业务的统一强制格式。

输入包含多于两位小数时,必须明确处理方式。更稳妥的默认值是拒绝输入,要求调用方先在有审计记录的边界执行明确舍入,而不是在格式化函数内部悄悄改变金额。

英文数字规则

基数词、分节和序数词

21 到 99 的复合数字通常使用连字符,例如 twenty-oneninety-nine。英文按三位分组:

数值 常见表达
1,000 one thousand
1,000,000 one million
1,000,000,000 one billion
1,000,000,000,000 one trillion

当前英式和美式英语通常都使用上表的短尺度,但历史文件或翻译文本可能存在不同约定。跨国合同应同时保留阿拉伯数字,必要时定义尺度。twenty-first 是序数词,不能与基数词 twenty-one 混用。

英式、美式与金额表达

英式英语常见 one hundred and five,美式英语常见 one hundred five;两者都应视为可配置的风格差异。金额中的 and 也不能自动推导支票格式。

$ 在跨境场景中并不能唯一标识货币,“one fifty”是非正式的美国口语,不适合当作通用文书格式。应明确货币,例如 one US dollar and fifty cents。支票的金额行、大小写、00/100 后缀和填线要求由签发机构及适用司法辖区决定,不能写成全球统一规则。

精确实现

JavaScript:用十进制字符串处理人民币金额

下面的示例只接受十进制字符串,并拒绝超过两位小数的输入,避免 toFixed(2)Number 带来的隐式舍入。它适合作为输入契约示例;完整产品仍需为负数、超大整数和文书规则补充测试。

javascript
const DIGITS = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"];
const SMALL_UNITS = ["", "拾", "佰", "仟"];
const LARGE_UNITS = ["", "万", "亿", "万亿"];

function groupToChinese(group) {
  const digits = String(group).padStart(4, "0").split("").map(Number);
  let result = "";
  let pendingZero = false;
  digits.forEach((digit, index) => {
    const unit = SMALL_UNITS[3 - index];
    if (digit === 0) {
      if (result) pendingZero = true;
      return;
    }
    if (pendingZero) result += "零";
    result += DIGITS[digit] + unit;
    pendingZero = false;
  });
  return result;
}

function integerToFinancialChinese(input) {
  if (!/^\d+$/.test(input)) throw new TypeError("请输入非负整数");
  if (input === "0") return "零";
  const groups = [];
  for (let end = input.length; end > 0; end -= 4) {
    groups.unshift(input.slice(Math.max(0, end - 4), end));
  }
  if (groups.length > LARGE_UNITS.length) throw new RangeError("数值超出示例范围");

  const parts = [];
  let pendingZero = false;
  groups.forEach((group, index) => {
    const value = Number(group);
    if (value === 0) {
      if (parts.length) pendingZero = true;
      return;
    }
    const needsZero = pendingZero || (parts.length > 0 && value < 1000);
    if (needsZero) parts.push("零");
    parts.push(
      groupToChinese(value) + LARGE_UNITS[groups.length - 1 - index],
    );
    pendingZero = false;
  });
  return parts.join("");
}

export function rmbToWords(decimalText) {
  if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(decimalText)) {
    throw new TypeError("金额必须是非负十进制字符串,最多两位小数");
  }
  const [yuanText, fraction = ""] = decimalText.split(".");
  const jiao = Number((fraction + "00")[0]);
  const fen = Number((fraction + "00")[1]);
  let result = `人民币${integerToFinancialChinese(yuanText)}元`;
  if (jiao === 0 && fen === 0) return `${result}整`;
  if (jiao) result += `${DIGITS[jiao]}角`;
  if (fen) result += `${DIGITS[fen]}分`;
  return result;
}

console.log(rmbToWords("1234.56"));
// 人民币壹仟贰佰叁拾肆元伍角陆分

实际使用前应补充分组之间的“零”规则测试;示例刻意拒绝负数和超过“万亿”的值,以免把未定义的业务语义伪装成完整财务库。若产品需要退款、红字发票或更大金额,应先定义对应的文书规范和符号约定。

Python:用整数最小单位或 Decimal

金额优先以分为单位传入;如果来源是十进制文本,则用 Decimal 解析,并在边界显式声明舍入策略:

python
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN


def rmb_to_fen(text: str, *, rounding=ROUND_HALF_EVEN) -> int:
    try:
        amount = Decimal(text)
    except InvalidOperation as exc:
        raise ValueError("金额必须是十进制字符串") from exc
    if not amount.is_finite() or amount < 0:
        raise ValueError("金额必须是非负有限数")
    fen = (amount * 100).quantize(Decimal("1"), rounding=rounding)
    if fen != amount * 100:
        raise ValueError("输入需要舍入,请在明确的业务边界执行")
    return int(fen)

不要使用 round((amount - dollars) * 100) 计算分,因为 float 的二进制表示可能使看似两位小数的金额落在临界值另一侧。若确实允许舍入,应记录原始字符串、舍入模式、货币小数位和最终整数分。

测试、无障碍与审计

至少测试 01101120211001011,0011,000,001、负数、最大支持范围、非法字符串、前导零和科学计数法。金额还应测试 0.01、整数金额、超过两位小数、负数和舍入临界值。审批文书前,应同时比较数值形式和文字形式。

输出应使用可复制的普通空格和标点,不要仅靠颜色区分金额。屏幕阅读器应能读到与视觉内容一致的文本。英文连字符、不可断行空格、大小写、RTL 语言和复制粘贴行为都需要地区化测试。

发票、支票和合同系统应保存原始数值、语言与地区、货币、库及版本、舍入模式、最终文字、校验结果和操作者/时间。文字金额本身不能证明授权人,也不能证明文件没有被改动。

常见问题

大写金额能防止欺诈吗?

不能。它可以帮助人工核对,也可能是某类表单的格式要求,但不提供完整性或授权能力。需要这些属性时,应使用签名、访问控制、审计记录和防篡改机制。

英文数字什么时候必须加 and

没有适用于所有地区和文书的单一规则。英式、美式风格不同,支票还受签发机构要求约束。应把风格作为配置,并按目标文书核对。

JavaScript 的 Number 能直接处理金额吗?

只有在值可精确表示且处于明确范围内时才可以。外部输入或大额金额应优先使用最小货币单位字符串、bigint 或十进制库。

中文财务大写在所有地方都具有法律效力吗?

不具有。财务大写的前缀、单位、结尾和更正要求取决于文书、机构和适用地区。应遵循签发机构的现行说明。

多出两位小数时应自动四舍五入吗?

通常不应静默舍入,因为这可能改变金额。应拒绝输入,或在有记录的业务边界使用明确命名的舍入策略。

总结

高质量的数字转文字功能需要明确契约、精确数值处理、地区化语言规则,以及对数值和文字两种表示的共同校验。支票和法律文书示例应当被视为特定地区的模板,而不是普遍法律。最可靠的实现会拒绝歧义、记录假设,并把授权、审计和文书完整性留给真正负责这些属性的系统。

参考资料