数字转文字首先是一种格式化操作,而不是安全控制。文字金额有助于人工核对,也可能是某类表单的格式要求,但它不能替代授权、签名、审计日志、防篡改存储,或银行、法院、税务机关和合同适用的具体规则。输出还会受到语言、地区、货币、文书类型和原始精度影响。
核心要点
- 写代码前先定义契约:语言、地区、基数词或序数词、分节方式、货币、最小货币单位和舍入策略。
- 金额优先使用最小货币单位的整数或精确十进制值,不要通过二进制浮点数相减来计算角分。
- 英式和美式英语在
and、标点和支票格式上存在差异;不能把一种写法当成全球通用规则。 - 普通中文数字与中文财务大写(大写金额)不是同一种表示。机构文书必须以适用的现行规范为准。
- 对格式错误、含义不明确、超出范围或需要隐式舍入的输入,应拒绝处理,而不是生成看似合理的文本。
数字转文字到底做什么
转换器应把经过校验的数值映射为特定语言的字符串,不应擅自推断货币、改变数值或判断文书是否具有法律效力。生产系统至少应记录:
| 契约字段 | 示例 |
|---|---|
| 输入表示 | 123456 个最小货币单位 |
| 语言与地区 | zh-CN、en-US、en-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,001、100,010 和 1,010,001,不能只测试整百数字。
人民币金额
常见的人民币财务大写中,¥1,234.56 可以写作“人民币壹仟贰佰叁拾肆元伍角陆分”,整数金额可以写作“人民币壹佰元整”。是否使用“元”或“圆”、是否以“整”或“正”结尾、是否必须加“人民币”前缀,以及空位填写方式,都可能取决于文书和机构。没有适用规范的来源时,不要声称某种形式是所有发票、支票或中国人民银行业务的统一强制格式。
输入包含多于两位小数时,必须明确处理方式。更稳妥的默认值是拒绝输入,要求调用方先在有审计记录的边界执行明确舍入,而不是在格式化函数内部悄悄改变金额。
英文数字规则
基数词、分节和序数词
21 到 99 的复合数字通常使用连字符,例如 twenty-one、ninety-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 带来的隐式舍入。它适合作为输入契约示例;完整产品仍需为负数、超大整数和文书规则补充测试。
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 解析,并在边界显式声明舍入策略:
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 的二进制表示可能使看似两位小数的金额落在临界值另一侧。若确实允许舍入,应记录原始字符串、舍入模式、货币小数位和最终整数分。
测试、无障碍与审计
至少测试 0、1、10、11、20、21、100、101、1,001、1,000,001、负数、最大支持范围、非法字符串、前导零和科学计数法。金额还应测试 0.01、整数金额、超过两位小数、负数和舍入临界值。审批文书前,应同时比较数值形式和文字形式。
输出应使用可复制的普通空格和标点,不要仅靠颜色区分金额。屏幕阅读器应能读到与视觉内容一致的文本。英文连字符、不可断行空格、大小写、RTL 语言和复制粘贴行为都需要地区化测试。
发票、支票和合同系统应保存原始数值、语言与地区、货币、库及版本、舍入模式、最终文字、校验结果和操作者/时间。文字金额本身不能证明授权人,也不能证明文件没有被改动。
常见问题
大写金额能防止欺诈吗?
不能。它可以帮助人工核对,也可能是某类表单的格式要求,但不提供完整性或授权能力。需要这些属性时,应使用签名、访问控制、审计记录和防篡改机制。
英文数字什么时候必须加 and?
没有适用于所有地区和文书的单一规则。英式、美式风格不同,支票还受签发机构要求约束。应把风格作为配置,并按目标文书核对。
JavaScript 的 Number 能直接处理金额吗?
只有在值可精确表示且处于明确范围内时才可以。外部输入或大额金额应优先使用最小货币单位字符串、bigint 或十进制库。
中文财务大写在所有地方都具有法律效力吗?
不具有。财务大写的前缀、单位、结尾和更正要求取决于文书、机构和适用地区。应遵循签发机构的现行说明。
多出两位小数时应自动四舍五入吗?
通常不应静默舍入,因为这可能改变金额。应拒绝输入,或在有记录的业务边界使用明确命名的舍入策略。
总结
高质量的数字转文字功能需要明确契约、精确数值处理、地区化语言规则,以及对数值和文字两种表示的共同校验。支票和法律文书示例应当被视为特定地区的模板,而不是普遍法律。最可靠的实现会拒绝歧义、记录假设,并把授权、审计和文书完整性留给真正负责这些属性的系统。