开始前,会这些就够了
- 能读懂 Python 字典、数据类、循环与 assert 断言
- 理解整数与字符串是不同的输入类型,知道金额计算必须保留币种和单位
- 先读参数格式与业务规则,本篇继续处理不同工具的字段含义
助手读取两份支出:工具甲返回 amount: 1999,工具乙返回 amount: "19.99",币种都写 USD。把它们相加之前,先问一句:甲的 1999 到底是美元,还是美分?如果甲约定整数表示美分,乙约定字符串表示美元,两笔其实都是 19.99 USD。就算计算器一位小数也没算错,单位理解错了,答案仍然会错。
这次用两套虚构工具返回值做一份离线报告:先按来源解释金额,再保留逐条转换记录,最后分别汇总 USD 与 JPY。重点是让数据进入求和前已有明确含义;已有金额精度课程中的 BigDecimal 构造、比较与舍入不在这里重讲。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- normalize_expense_tools.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B normalize_expense_tools.py解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 两套虚构工具的离线金额约定与固定输入;不连接支付服务,不换汇,也不证明真实账单完整。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先看约定,再看 amount 的数值
给两套接口各写一张说明卡:checkout-minor-v1 只收最小单位整数,expense-major-v1 只收主单位十进制字符串。卡片在程序里叫 Contract。字段同名、JSON 能解析,都不能代替这份含义约定;更不能看到 1999 很大,就猜它一定应该除以一百。
现实接口也有自己的写法。Stripe 的金额说明用最小单位解释请求金额,示例中 1000 对应 10 USD,而 10 对应 10 JPY;页面还单列部分币种与操作的特殊条件。这个例子支持“先读对应接口”,不支持“所有 amount 都是分”。本项目两套来源均为虚构,不是 Stripe 适配器。
来源标签由接入代码绑定
Envelope 把来源契约编号与 payload 放在一起。应用知道调用的是哪个已登记工具,才附上对应编号;不让记录正文自称“我按美元解释”,也不让模型看数值猜来源。未知编号直接拒绝,避免给新工具套用一个碰巧能解析的旧规则。
这里的编号只是本地映射键,没有验证网络来源或登录身份。输入是 main 明确构造的 Envelope;没有实现任意网络 JSON 的接入验证。真实接入还要核对工具、版本与操作,并检查来源真实性,不能把复制一个标签当成完成认证。
把 19.99 写成 1999,也要留下单位说明
本地两套契约都声明 USD 指数为 2、JPY 为 0。这里的指数 e 表示每个整数刻度等于 10 的负 e 次方个主单位:1999 配指数 2 才能显示成 19.99;1000 配指数 0 则显示成 1000。MoneyRow 同时保留币种、指数和整数值,不能只把 1999 传给下一步。
这张小表不是全球币种标准。Adyen 的文档明确列出 Terminal API 例外,也说明部分币种的接口小数位与 ISO 4217 不同。因此规则要绑定具体来源,不能只查币种名称后向所有供应商套用;本例没有实现这些真实供应商的特殊规则。
比如主单位来源的 "19.9",按本地两位约定补成两个小数位,再得到整数刻度;原始文本仍保留,不会被显示格式覆盖。转换只是换一种表示,同一币种的价值没有变化。换汇却是在两种币种之间建立兑换关系,需要另一套依据,两件事不能因为都出现乘法就混为一谈。
逐行转换,不替错误输入做决定
整数来源要求真正的 int,字符串 "250" 不会偷偷转成整数;Python 的 True 虽然能参与整数运算,这里也明确拒绝。主单位来源先检查文本形式,再拆开整数和小数部分、补足所需位数,全程不经过 float。"1.005" 超过本地 USD 两位约定,会被拒绝,不会自行舍入。
缺币种、未知币种、未知来源、负数及超过单行上限也各有拒绝原因。零金额是有效样本,不能用真假判断把它丢掉。成功行保留原值和转换值,失败行保留原记录与原因;这份逐条可回看的转换账本,比只留下一个合计更方便检查。
读账本时按一个固定顺序:先核对来源和原记录编号,再看原值、币种及指数,最后比对整数值与显示文本。发现问题就能定位到具体输入,不必从最终合计倒猜是哪一笔出了错。拒绝列表也保留输入行号,方便把原因交给数据提供方处理,而不是让助手凭常识补齐。
五行接纳、四行拒绝,就只能报告部分小计
九行样本里,五行被接纳,另外四行分别因为小数位超限、整数来源收到字符串、币种为空、来源未登记而被拒绝。报告是 partial:JPY 1500、USD 39.98 都只是已接纳记录的小计。不能把它改写成“全部支出已统计”,也不能静默忽略失败后输出成功。
分组键含币种与指数,避免不同刻度直接相加。combined_total 始终为 null,因为本例没有汇率来源、估值时间或换汇规则。若后续出现同币种但指数不同的来源,也只会分组保留,不会暗中合并;展示层同样要保留指数,或等有明确转换约定后再处理。
给用户的文字应紧贴报告:本批九条记录仅五条进入统计,列出各币种小计,并说明四条尚未处理。即使全部样本通过,accepted_fixture 也只代表收到的夹具记录被接纳;它没有检查工具是否漏返回一页数据,更不能据此断言某个真实账号的全部消费已经取齐。
完整运行:从原值一路查到报告
保存完整代码为 normalize_expense_tools.py,用 Python 3.10 或更新版本运行。只用标准库,无需账号;不联网、不调用语言模型或支付接口,也没有真实支付。请在单独练习目录运行,脚本会在当前目录写 normalized-expense-report.json。所有金额、来源和记录均为虚构。
先对照前两行日志:不同原值都变为 1999 个 USD 最小单位。再打开 JSON,从小计回看 ledger,再看 rejected 的原记录。脚本还用断言覆盖零金额、重复来源编号、布尔值、未知币种、日元小数、科学计数法、负值、上限、空输入及原输入不被修改。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Two fictional read-only tool contracts; no API, FX, floats or real payments.
All fixture rows describe distinct settled expenses in one declared scope.
Source contract IDs are attached by the host, not inferred from payload text.
"""
from copy import deepcopy
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import re
@dataclass(frozen=True)
class Contract:
encoding: str
exponents: tuple[tuple[str, int], ...]
# Local exercise policies, NOT a worldwide currency or provider registry.
CONTRACTS = {
"checkout-minor-v1": Contract("minor_integer", (("USD", 2), ("JPY", 0))),
"expense-major-v1": Contract("major_text", (("USD", 2), ("JPY", 0))),
}
MAX_MINOR = 10**12
@dataclass(frozen=True)
class Envelope:
source_contract: str
payload: dict
@dataclass(frozen=True)
class MoneyRow:
source_contract: str
record_id: str
currency: str
exponent: int
amount_minor: int
original_amount: int | str
def normalize(envelope: Envelope) -> MoneyRow:
contract = CONTRACTS.get(envelope.source_contract)
if contract is None:
raise ValueError("unknown_source_contract")
row = envelope.payload
if not isinstance(row, dict) or set(row) != {"id", "currency", "amount"}:
raise ValueError("unexpected_record_fields")
if not isinstance(row["id"], str) or not row["id"]:
raise ValueError("missing_record_identity")
if not isinstance(row["currency"], str):
raise ValueError("missing_currency")
exponents = dict(contract.exponents)
if row["currency"] not in exponents:
raise ValueError("unsupported_currency")
exponent = exponents[row["currency"]]
raw = row["amount"]
if contract.encoding == "minor_integer":
# bool is an int subclass in Python; it is not a valid amount here.
if type(raw) is not int:
raise ValueError("minor_amount_must_be_integer")
amount = raw
elif contract.encoding == "major_text":
if not isinstance(raw, str) or not 1 <= len(raw) <= 32:
raise ValueError("major_amount_must_be_decimal_text")
if not re.fullmatch(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?", raw):
raise ValueError("invalid_decimal_text")
whole, _, fraction = raw.partition(".")
if len(fraction) > exponent:
raise ValueError("precision_exceeds_source_contract")
amount = int(whole) * 10**exponent + int(fraction.ljust(exponent, "0") or "0")
else:
raise ValueError("unsupported_amount_encoding")
if not 0 <= amount <= MAX_MINOR:
raise ValueError("amount_out_of_fixture_range")
return MoneyRow(envelope.source_contract, row["id"], row["currency"], exponent, amount, raw)
def display_amount(amount_minor: int, exponent: int) -> str:
whole, fraction = divmod(amount_minor, 10**exponent)
return str(whole) if exponent == 0 else f"{whole}.{fraction:0{exponent}d}"
def build_report(envelopes: list[Envelope]) -> dict:
ledger, rejected = [], []
seen = set()
totals: dict[tuple[str, int], int] = {}
for index, envelope in enumerate(envelopes, start=1):
try:
normalized = normalize(envelope)
key = (normalized.source_contract, normalized.record_id)
if key in seen:
raise ValueError("duplicate_source_record")
seen.add(key)
group = (normalized.currency, normalized.exponent)
totals[group] = totals.get(group, 0) + normalized.amount_minor
ledger.append({**asdict(normalized), "display": display_amount(normalized.amount_minor, normalized.exponent)})
except ValueError as error:
rejected.append({"input_index": index, "source_contract": envelope.source_contract,
"original_payload": deepcopy(envelope.payload), "reason": str(error)})
# Group includes exponent, so a future mismatched policy cannot silently add units.
groups = [{"currency": currency, "exponent": exponent, "amount_minor": amount,
"display": display_amount(amount, exponent)}
for (currency, exponent), amount in sorted(totals.items())]
return {"status": "partial" if rejected else ("accepted_fixture" if envelopes else "empty_input"),
"scope": "distinct settled fixture expenses; only accepted rows included",
"ledger": ledger, "rejected": rejected, "groups": groups,
"combined_total": None, "combined_total_reason": "no_currency_conversion_contract"}
def must_reject(envelope: Envelope, reason: str) -> None:
try:
normalize(envelope)
except ValueError as error:
assert str(error) == reason, (str(error), reason)
else:
raise AssertionError("expected rejection")
def main() -> None:
minor, major = "checkout-minor-v1", "expense-major-v1"
records = [
Envelope(minor, {"id": "A1", "currency": "USD", "amount": 1999}),
Envelope(major, {"id": "B1", "currency": "USD", "amount": "19.99"}),
Envelope(minor, {"id": "A2", "currency": "JPY", "amount": 1000}),
Envelope(major, {"id": "B2", "currency": "JPY", "amount": "500"}),
Envelope(major, {"id": "B0", "currency": "USD", "amount": "0.00"}),
Envelope(major, {"id": "B3", "currency": "USD", "amount": "1.005"}),
Envelope(minor, {"id": "A3", "currency": "JPY", "amount": "250"}),
Envelope(major, {"id": "B4", "currency": None, "amount": "7.00"}),
Envelope("unregistered-tool", {"id": "X1", "currency": "USD", "amount": 12}),
]
original = deepcopy(records)
report = build_report(records)
assert records == original
assert len(report["ledger"]) == 5 and len(report["rejected"]) == 4
assert report["status"] == "partial" and report["combined_total"] is None
assert [(row["currency"], row["amount_minor"], row["display"]) for row in report["groups"]] == [
("JPY", 1500, "1500"), ("USD", 3998, "39.98")]
assert report["ledger"][0]["amount_minor"] == report["ledger"][1]["amount_minor"] == 1999
assert report["ledger"][4]["amount_minor"] == 0
assert [row["reason"] for row in report["rejected"]] == [
"precision_exceeds_source_contract", "minor_amount_must_be_integer", "missing_currency", "unknown_source_contract"]
must_reject(Envelope(minor, {"id": "bool", "currency": "USD", "amount": True}), "minor_amount_must_be_integer")
must_reject(Envelope(major, {"id": "unknown", "currency": "EUR", "amount": "1.00"}), "unsupported_currency")
must_reject(Envelope(major, {"id": "fraction-yen", "currency": "JPY", "amount": "1.1"}), "precision_exceeds_source_contract")
must_reject(Envelope(major, {"id": "exponent", "currency": "USD", "amount": "1e2"}), "invalid_decimal_text")
must_reject(Envelope(minor, {"id": "negative", "currency": "USD", "amount": -1}), "amount_out_of_fixture_range")
must_reject(Envelope(minor, {"id": "huge", "currency": "USD", "amount": MAX_MINOR + 1}), "amount_out_of_fixture_range")
duplicate = build_report([records[0], records[0]])
assert len(duplicate["ledger"]) == 1 and duplicate["rejected"][0]["reason"] == "duplicate_source_record"
assert build_report([])["groups"] == [] and build_report([])["status"] == "empty_input"
assert build_report([records[0]])["status"] == "accepted_fixture"
Path("normalized-expense-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("Accepted normalization ledger:")
for row in report["ledger"]:
print(f"{row['source_contract']}/{row['record_id']}: raw={row['original_amount']!r} -> "
f"{row['amount_minor']} x 10^-{row['exponent']} {row['currency']} = {row['display']} {row['currency']}")
print("Rejected input rows:")
for row in report["rejected"]:
print(f"row {row['input_index']}: {row['reason']}")
print("Accepted-only subtotals: JPY 1500; USD 39.98")
print("status=partial; accepted=5/9; combined_total=None")
print("PASS: source-bound units, exact integer normalization, currency-separated totals, rejection ledger")
if __name__ == "__main__":
main()
怎样运行:Python 3.10 或更新版本,在独立练习目录执行:python normalize_expense_tools.py。仅标准库;生成 normalized-expense-report.json。 2026-10-15 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。两套虚构工具的离线金额约定与固定输入;不连接支付服务,不换汇,也不证明真实账单完整。
运行后对照结果
Accepted normalization ledger:
checkout-minor-v1/A1: raw=1999 -> 1999 x 10^-2 USD = 19.99 USD
expense-major-v1/B1: raw='19.99' -> 1999 x 10^-2 USD = 19.99 USD
checkout-minor-v1/A2: raw=1000 -> 1000 x 10^-0 JPY = 1000 JPY
expense-major-v1/B2: raw='500' -> 500 x 10^-0 JPY = 500 JPY
expense-major-v1/B0: raw='0.00' -> 0 x 10^-2 USD = 0.00 USD
Rejected input rows:
row 6: precision_exceeds_source_contract
row 7: minor_amount_must_be_integer
row 8: missing_currency
row 9: unknown_source_contract
Accepted-only subtotals: JPY 1500; USD 39.98
status=partial; accepted=5/9; combined_total=None
PASS: source-bound units, exact integer normalization, currency-separated totals, rejection ledger单位一致,还不代表两行是两笔不同支出
本题事先声明:两个来源中的样本是同一统计范围内互不重复的已完成支出。代码只拒绝重复的“来源契约编号+记录编号”,这是防止同一来源记录重复计入,不能识别两套系统是否记录了同一笔消费。不要看到编号不同或金额相同,就替真实业务决定合并或分开。
金额还有更多表示方式。Google Cloud 的 Money分别定义币种、整单位 units 与十亿分之一单位 nanos,并规定符号与范围。nanos 不是美分。本程序没有这套解析器,不能把其字段改名为 amount 就声称接入完成;遇到新来源应先明确映射,再补独立验证。
验收报告,也验收它没有证明的事
2026-10-15 已在独立临时目录实际执行这份脚本,九行输入得到五行账本、四行拒绝和两个币种小计,全部内置断言通过。文章代码与练习文件按统一换行后相同。这个证据证明固定夹具下的转换与报告流程,不证明真实工具数据完整,也不证明模型会如实转述。
交付脚本、运行日志、JSON 报告和两套来源约定说明,并让读者能逐行追溯小计。范围仅含非负样本支出;没有退款、税费、跨来源业务去重、来源完整性、认证或并发处理。之后增加真实工具时,应分别验证这些问题,不能把本地 PASS 当成财务系统验收。
记住这三点
- 同名金额字段先按可信来源契约解释,再进入共同账本;不靠数值大小猜单位。
- 保留原值、币种、指数、规范值和拒绝原因,让每个小计都能回到输入记录。
- 部分接纳就报告部分结果;无换汇契约时按币种分别列出,不制造跨币种总额。
别把例子用过头
- 把某一家供应商的金额格式、小数位或特殊情况当成所有接口的通用规则。
- 看到格式能转换就自动舍入、补币种或改类型,让无效输入悄悄进入统计。
- 把来源内编号检查当成跨来源业务去重,或把已接纳小计说成全部真实支出。
自己试一小步
另有一个虚构工具按千分之一主单位返回整数,你能写出清晰的新契约,并阻止旧汇总流程误加不同单位吗?
- 在独立副本中设计第三个来源、至少六条新记录及预期接纳或拒绝表;明确币种、刻度、字段类型、范围和来源编号。不要照抄主示例数值。
- 实现该来源到共同账本的映射,分别加入同币种不同刻度、未知来源、零金额与超限记录;自行决定哪些转换有依据,哪些必须拒绝。
- 设计两条不同来源但可能对应同一笔消费的记录,说明需要哪些额外业务证据;在证据缺失时明确限制报告范围。
- 交付运行日志、逐条转换与拒绝表,以及一段供助手展示的报告文字;保留币种、已接纳范围与未处理项。
做到这些,就算完成
- 每个被接纳的整数是否都有明确来源、币种及刻度,原值能否追查?
- 无效记录是否保留原因,拒绝是否改变报告状态,零是否仍能正确进入账本?
- 不同币种或刻度是否避免无依据相加,是否把单位规范化与业务去重分别解释?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Stripe:请求金额、最小单位与特殊情况 (新标签页)↗
核读 Specify amounts in API requests、Zero-decimal currencies 与 Special cases;支持该 API 的金额单位及特定币种、操作的例外。不是所有工具的通用金额协议。
- Adyen:币种表、最小单位与小数位差异 (新标签页)↗
核读 Minor units 与 Different number of decimals;支持 Terminal API 例外,以及部分币种提交金额时应遵循该接口表中的小数位。未将供应商规则推广到其他来源。
- Google Cloud:Money 的币种、整单位和 nanos (新标签页)↗
核读 JSON representation 及
currencyCode、units、nanos 字段;支持另一套明确区分币种、整单位和十亿分之一单位的金额表示。程序没有实现该协议。