关于 value-objects-money-and-units
面向 Dart 领域逻辑的值对象技能,规定货币、物理量和时间的规范存储与边界转换,并以整数分配保证拆分金额合计准确。
主要功能
- 01以整数最小货币单位存储金额
- 02按 ISO-4217 指数处理货币精度
- 03通过 allocate() 分配金额
- 04物理量按 SI 基本单位存储
- 05注入 Clock 实现可测试时间逻辑
适用场景
- 01实现多人账单拆分与分摊
- 02避免不同币种的金额精度错误
- 03为数量和时间设计规范存储模型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill value-objects-money-and-units安装后在 Claude Code 中输入/value-objects-money-and-units确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/allocate.dartmoney.dartreferences/allocate-and-splitting.mdcanonical-storage.mddomain-model.mdscripts/check-money-violations.shverify-core.sh详细介绍
值对象:金额与计量单位
每个数量都应只存储一次,采用一种规范形式,并且仅在显示或导出时转换。金额以货币和整数最小货币单位存储;物理量以整数 SI 基本单位存储;时间使用 UTC。这样,用户可以切换任意显示偏好(货币符号、单位制、本地化设置),而不会破坏任何已存储的数据行,也能确保“各部分之和等于整体”是结构上成立的,而非寄希望于此。
此核心层使用纯 Dart——不依赖 flutter/*、intl、dart:io 或插件。格式化和数字规范化在上游或下游进行,而非在此处进行。这些值对象位于 lib/core/,这是获准使用的纯基础层(参见 project-structure-and-packages)——切勿将其放进 utils/、common/ 或 shared/ 这类杂物目录。
请根据当前任务阅读相应参考文档:
references/allocate-and-splitting.md— 最大余数法allocate()基元、其不变量、双重舍入陷阱、边界策略、经验证的测试向量,以及小计→税费→小费的拆分流程。references/canonical-storage.md— ISO-4217 指数规则、SI 单位表、基于decimal的解析、用于键盘输入的分累加器、舍入规范,以及注入Clock的带日期汇率/过期判定引擎。references/domain-model.md— 值类型建模:关系通过 ID 链接;能推导就不存储;每个聚合仅使用一种货币;不可变状态及值相等性。
提交 PR 前,请运行 scripts/check-money-violations.sh 和 scripts/verify-core.sh。
不可妥协的规则
- 金额必须使用
int次级货币单位 +Currency——绝不能使用double/num/REAL。 二进制浮点数无法准确表示0.01;误差会悄无声息地破坏总额,用户永远无法重新推算出正确结果。任何金额 API 都不得接受或返回double。 - 根据货币实际的 ISO-4217 指数推导每个主要货币单位对应的次级货币单位数——绝不能硬编码
* 100、/ 100或“保留 2 位小数”。 JPY/VND 的指数为 0,USD/EUR 为 2,KWD/BHD/OMR 为 3。对指数为 0 的货币,硬编码100会造成 100 倍误差;对指数为 3 的货币,则会造成 10 倍误差。统一通过currency.minorPerMajor获取。 - 未知货币代码必须产生有类型的失败,绝不能静默默认指数为 2。 指数表只列出已内置的货币;
Currency.tryParse返回 null,由调用方发出Failure。 - 一个聚合只能使用一种货币;禁止跨货币运算。 将两种不同货币的
Money相加属于类别错误——应抛出异常(程序员错误),或先通过外汇层进行换算。将货币视为所属聚合的属性,这样Money运算就无需再检查货币是否一致。 - 所有金额拆分都必须通过同一个
allocate(amount, weights)原语。 共享项目、税费分摊、小费分摊、折扣——统一使用一种舍入路径,从而只需证明和测试一次“各部分之和与整体在次级货币单位上精确相等”。 - 有两个舍入点,而不是一个:
allocate()和百分比→次级货币单位。 在传入allocate()前,先将百分比舍入为整数次级货币单位,且只舍入一次。简单地使用double表示百分比,是典型的差一分钱错误,allocate的覆盖测试无法发现。 - 绝不能将分别舍入后的各部分相加来计算总额。 始终对已知的整数总额调用
allocate(),由各部分吸收余数。 - 总额应推导得出,绝不存储。 反规范化存储的总额是典型的误差累积隐患。读取时根据项目和权重计算总额。
- 关系应建模为稳定的
id链接,而不是嵌入式副本。 为每个实体提供显式的final id(例如 UUID)。编辑价格后,分配关系仍保持不变;删除参与者时,只需将其从链接集合中移除。 - 存储规范化值,在边界处换算。 物理量使用整数 SI 基本单位(
int米 / 毫升 / 分钟);时间使用 UTCDateTime。.from<DisplayUnit>工厂负责舍入并转换为规范化值;to<DisplayUnit>()getter 返回double,仅用于展示层边界。 - 在输入进入此核心之前,将数字和分隔符规范化为 ASCII。 绝不能对原始本地化输入调用
int.parse/double.parse/Decimal.parse——遇到东阿拉伯数字时会抛出异常。在上游完成转换(参见i18n-rtl-l10n)。 - 使用
decimal进行解析;在边界处通过显式模式舍入一次。 使用package:decimal进行精确除法和解析,并在解析或最终总额边界处显式应用RoundingMode(默认使用银行家舍入,即 half-even),且只舍入一次——绝不能在中间求和时舍入(这会累积偏差)。 - 注入
package:clock中的Clock;绝不能调用DateTime.now(),也绝不能另行实现ClockService。 这个纯核心中的每个读取时间的类都必须接收Clock构造参数;Riverpod/功能代码通过clockProvider注入同一个Clock(参见service-boundary-and-native),以便两套术语体系能够协同使用。固定时钟 /fake_async可确保测试中的时间相关逻辑具有确定性。 - 核心不得依赖 Flutter 或 IO。 依赖项仅限
decimal和clock。格式化放在展示层;存储放在数据层。
规范存储
组件和仓库交换的是值对象,绝不交换原始 int 或字符串。
| 数量 | 规范存储 | 类型 | 绝不存储 |
|---|---|---|---|
| 金额 | 整数最小货币单位 + Currency | int | double、格式化字符串 |
| 距离 | 整数米 | int | km、英里 |
| 体积 | 整数毫升 | int | 升、加仑 |
| 时长 | 整数分钟(或 Duration) | int | double 表示的小时 |
| 时间戳 | UTC ISO-8601 时刻 | DateTime(UTC) | 本地时间 |
/// ISO-4217 minor-unit exponents. Explicit table — NEVER default to 2.
enum Currency {
jpy('JPY', 0), vnd('VND', 0),
usd('USD', 2), eur('EUR', 2), gbp('GBP', 2),
kwd('KWD', 3), bhd('BHD', 3), omr('OMR', 3);
const Currency(this.code, this.exponent);
final String code;
final int exponent;
/// 10^exponent — minor units per major unit. The ONLY scaling source.
int get minorPerMajor => switch (exponent) {
0 => 1,
2 => 100,
3 => 1000,
_ => throw StateError('unsupported exponent $exponent for $code'),
};
static Currency? tryParse(String code) {
// Plain loop, not `firstOrNull` (a package:collection extension) — keeps the
// core dependency-free beyond `decimal`.
for (final c in Currency.values) {
if (c.code == code) return c;
}
return null;
}
}
/// Money is (integer minor units) + (currency). No floats, ever.
final class Money implements Comparable<Money> {
const Money(this.minorUnits, this.currency);
final int minorUnits; // e.g. 12345 with KWD == 12.345 KWD
final Currency currency;
Money operator +(Money o) => currency == o.currency
? Money(minorUnits + o.minorUnits, currency)
: throw ArgumentError('currency mismatch: $currency vs ${o.currency}');
@override
int compareTo(Money o) {
assert(currency == o.currency, 'compare across currencies is a bug');
return minorUnits.compareTo(o.minorUnits);
}
@override
bool operator ==(Object o) =>
o is Money && o.minorUnits == minorUnits && o.currency == currency;
@override
int get hashCode => Object.hash(minorUnits, currency);
}
物理量值对象遵循完全相同的结构——使用整数规范字段、舍入型 .from<Unit> 工厂方法,以及仅用于边界转换的 to<Unit>() getter。参见 references/canonical-storage.md 和 examples/money.dart。
唯一的除法路径:allocate()
每次拆分金额时,都必须使用这个整数最大余数(Hamilton)原语。切勿以其他方式拆分金额。
/// Splits [amount] minor units across [weights], guaranteeing the parts sum
/// EXACTLY to [amount]. Deterministic ascending-index tie-break; residual < n.
/// Negative amount mirrors and negates (discounts/refunds); zero weight-sum
/// falls back to equal weights; empty weights returns [] — money math must
/// never throw into the UI.
List<int> allocate(int amount, List<int> weights) {
final n = weights.length;
if (n == 0) return const [];
if (amount < 0) return allocate(-amount, weights).map((s) => -s).toList();
final sanitized = [for (final w in weights) w < 0 ? 0 : w];
final weightSum = sanitized.fold(0, (a, b) => a + b);
final w = weightSum == 0 ? List.filled(n, 1) : sanitized;
final total = weightSum == 0 ? n : weightSum;
final shares = List<int>.filled(n, 0);
final remainders = <({int remainder, int index})>[];
var distributed = 0;
for (var i = 0; i < n; i++) {
final product = amount * w[i]; // multiply FIRST, then ~/ and % — no float
final floorShare = product ~/ total;
shares[i] = floorShare;
distributed += floorShare;
remainders.add((remainder: product % total, index: i));
}
var leftover = amount - distributed; // always in 0 ..< n
remainders.sort((a, b) => a.remainder != b.remainder
? b.remainder.compareTo(a.remainder)
: a.index.compareTo(b.index));
for (var k = 0; leftover > 0; k++, leftover--) {
shares[remainders[k].index] += 1;
}
assert(shares.fold(0, (a, b) => a + b) == amount, 'allocate must be exact');
return shares;
}
已验证的向量(请在测试中断言):
allocate(1001,[1,1,1]) == [334,334,333] ·
allocate(660,[1584,4033,1933]) == [138,353,169] ·
allocate(1510,[1584,4033,1933]) == [317,807,386]。
拆分流水线分层调用 allocate():先分配商品小计,再调用
allocate(taxMinor, subtotals),然后调用 allocate(tipMinor, subtotals),这样整笔账单的每个数额都会得到分配,且每位参与者的最终金额之和与总额完全一致。完整流水线、边界策略以及将百分比舍入为最小货币单位的位置,见 references/allocate-and-splitting.md 和 examples/allocate.dart。
将输入解析为精确的最小货币单位
输入必须已在上游完成 ASCII 规范化。使用 decimal 进行解析(避免二进制误差),按货币的小数位数缩放,并只舍入一次。
/// Caller MUST have normalized digits + separators to ASCII first.
Money moneyFromMajorString(String ascii, Currency c) {
final scaled = (Decimal.parse(ascii) * Decimal.fromInt(c.minorPerMajor))
.round(); // exact; Decimal has no binary-float error
return Money(scaled.toBigInt().toInt(), c);
}
对于没有固定小数点按键的数字键盘,直接将数字累积为最小货币单位,绝不处理区域设置的小数分隔符——参见 references/canonical-storage.md。
派生,而非存储;注入 Clock
总额在读取时根据商品和权重计算;存储总额会导致数据漂移。任何依赖时间的值对象(日期费率、过时程度区间、到期时间)都应接收注入的 Clock,以确保测试结果确定。
final class RateSnapshot {
const RateSnapshot(this._clock);
final Clock _clock;
int ageDays(DateTime asOfUtc) => _clock.now().difference(asOfUtc).inDays;
}
// prod: RateSnapshot(const Clock()); test: RateSnapshot(Clock.fixed(fixedUtc));
反模式
double amount/num price字段。 无法表示0.01;会不可逆地破坏总额。请使用int最小货币单位。amount * 100/cents / 100。 对所有小数位数不是 2 的货币都不正确。请通过currency.minorPerMajor处理。- 将未知货币默认按 2 位小数处理。 会悄无声息地错误缩放。应返回有类型的失败。
- 跨货币相加
Money,或在整个聚合对象使用单一货币时为每个行项目存储货币。 在外汇边界进行转换;在聚合层级保留货币。 - 将独立舍入后的份额相加来得到总额。 这会舍入两次并产生偏差。应对已知的整数总额使用
allocate()。 - 在分配循环中按百分比重新计算税费/小费。 将百分比舍入为整数最小货币单位一次,然后使用
allocate()。 - 在实体上存储反规范化的
total。 应派生得出。 - 在纯核心中使用
DateTime.now(),或手写ClockService/SystemClock/FakeClock。 时间无法测试 / 无法组合。应注入package:clock的Clock。 - 使用
Decimal(someDouble),或对未经处理的本地化输入调用int.parse。 会产生二进制误差 / 在东阿拉伯数字上抛出异常。应从String解析Decimal;先进行规范化。 - 舍入中间和。 会累积偏差。只在边界处舍入一次。
完成标准
- 每个金额字段都使用
int表示最小货币单位,并配有一个Currency;不得使用double/num。 - 所有缩放都通过
currency.minorPerMajor完成;不得使用字面量100。 - 遇到未知货币时返回带类型的失败,绝不回退到默认解析结果。
- 不进行跨货币运算;货币在聚合级别定义。
- 每次金额拆分都调用
allocate();使用已验证的向量断言守恒,并进行测试。 - 百分比先舍入为整数最小货币单位,再调用
allocate()。 - 不存储总计;读取时计算总计。
- 实体具有明确且稳定的
id;关系通过 ID 链接。 - 物理量以 SI
int存储;仅在边界处转换。 - 读取时间的类接收注入的
Clock;核心代码中不得使用DateTime.now()。 - 核心代码只导入
decimal和clock;不得导入 Flutter/intl/dart:io。 -
scripts/check-money-violations.sh和scripts/verify-core.sh均通过。
相关技能
- 有关此纯核心代码在以功能为先的应用布局中的位置(
lib/core/,即获准使用的基础层),请参阅project-structure-and-packages。 - 有关通过
clockProvider将此Clock注入 Riverpod 代码(使用相同的package:clock接口,并由 provider 接线),请参阅service-boundary-and-native。 - 有关解析和 FX 使用而非抛出的密封
Result<T, F extends Failure>主干类型,请参阅error-handling-typed-results。 - 有关在展示层边界进行 ASCII 数字/分隔符规范化和货币格式化(这些操作不放在纯核心代码中),请参阅
i18n-rtl-l10n。 - 有关不可变值类型、密封类型和不会抛出异常的全定义域领域函数,请参阅
dart3-idioms-and-coding-standards。 - 有关存储最小货币单位 + ISO 代码(绝不使用 REAL)以及将行映射为这些值对象,请参阅
persistence-drift。 - 有关这些纯函数所需的注入时钟、表驱动单元测试,请参阅
testing-strategy。
参考资料
- ISO 4217 货币指数 — https://en.wikipedia.org/wiki/ISO_4217
package:decimal— https://pub.dev/packages/decimalpackage:clock— https://pub.dev/packages/clockfake_async— https://pub.dev/packages/fake_async- 最大余数(Hamilton)法 — https://en.wikipedia.org/wiki/Largest_remainder_method
- Dart 记录与模式 — https://dart.dev/language/records