mcpField
V
value-objects-money-and-units

value-objects-money-and-units

github.com/zakariaf开发63144 次安装5 周前更新
代码仓库

关于 value-objects-money-and-units

面向 Dart 领域逻辑的值对象技能,规定货币、物理量和时间的规范存储与边界转换,并以整数分配保证拆分金额合计准确。

主要功能

  1. 01以整数最小货币单位存储金额
  2. 02按 ISO-4217 指数处理货币精度
  3. 03通过 allocate() 分配金额
  4. 04物理量按 SI 基本单位存储
  5. 05注入 Clock 实现可测试时间逻辑

适用场景

  1. 01实现多人账单拆分与分摊
  2. 02避免不同币种的金额精度错误
  3. 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.dart
money.dart
references/
allocate-and-splitting.md
canonical-storage.md
domain-model.md
scripts/
check-money-violations.sh
verify-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。

不可妥协的规则

  1. 金额必须使用 int 次级货币单位 + Currency——绝不能使用 double/num/REAL。 二进制浮点数无法准确表示 0.01;误差会悄无声息地破坏总额,用户永远无法重新推算出正确结果。任何金额 API 都不得接受或返回 double。
  2. 根据货币实际的 ISO-4217 指数推导每个主要货币单位对应的次级货币单位数——绝不能硬编码 * 100、/ 100 或“保留 2 位小数”。 JPY/VND 的指数为 0,USD/EUR 为 2,KWD/BHD/OMR 为 3。对指数为 0 的货币,硬编码 100 会造成 100 倍误差;对指数为 3 的货币,则会造成 10 倍误差。统一通过 currency.minorPerMajor 获取。
  3. 未知货币代码必须产生有类型的失败,绝不能静默默认指数为 2。 指数表只列出已内置的货币;Currency.tryParse 返回 null,由调用方发出 Failure。
  4. 一个聚合只能使用一种货币;禁止跨货币运算。 将两种不同货币的 Money 相加属于类别错误——应抛出异常(程序员错误),或先通过外汇层进行换算。将货币视为所属聚合的属性,这样 Money 运算就无需再检查货币是否一致。
  5. 所有金额拆分都必须通过同一个 allocate(amount, weights) 原语。 共享项目、税费分摊、小费分摊、折扣——统一使用一种舍入路径,从而只需证明和测试一次“各部分之和与整体在次级货币单位上精确相等”。
  6. 有两个舍入点,而不是一个:allocate() 和百分比→次级货币单位。 在传入 allocate() 前,先将百分比舍入为整数次级货币单位,且只舍入一次。简单地使用 double 表示百分比,是典型的差一分钱错误,allocate 的覆盖测试无法发现。
  7. 绝不能将分别舍入后的各部分相加来计算总额。 始终对已知的整数总额调用 allocate(),由各部分吸收余数。
  8. 总额应推导得出,绝不存储。 反规范化存储的总额是典型的误差累积隐患。读取时根据项目和权重计算总额。
  9. 关系应建模为稳定的 id 链接,而不是嵌入式副本。 为每个实体提供显式的 final id(例如 UUID)。编辑价格后,分配关系仍保持不变;删除参与者时,只需将其从链接集合中移除。
  10. 存储规范化值,在边界处换算。 物理量使用整数 SI 基本单位(int 米 / 毫升 / 分钟);时间使用 UTC DateTime。.from<DisplayUnit> 工厂负责舍入并转换为规范化值;to<DisplayUnit>() getter 返回 double,仅用于展示层边界。
  11. 在输入进入此核心之前,将数字和分隔符规范化为 ASCII。 绝不能对原始本地化输入调用 int.parse/double.parse/Decimal.parse——遇到东阿拉伯数字时会抛出异常。在上游完成转换(参见 i18n-rtl-l10n)。
  12. 使用 decimal 进行解析;在边界处通过显式模式舍入一次。 使用 package:decimal 进行精确除法和解析,并在解析或最终总额边界处显式应用 RoundingMode(默认使用银行家舍入,即 half-even),且只舍入一次——绝不能在中间求和时舍入(这会累积偏差)。
  13. 注入 package:clock 中的 Clock;绝不能调用 DateTime.now(),也绝不能另行实现 ClockService。 这个纯核心中的每个读取时间的类都必须接收 Clock 构造参数;Riverpod/功能代码通过 clockProvider 注入同一个 Clock(参见 service-boundary-and-native),以便两套术语体系能够协同使用。固定时钟 / fake_async 可确保测试中的时间相关逻辑具有确定性。
  14. 核心不得依赖 Flutter 或 IO。 依赖项仅限 decimal 和 clock。格式化放在展示层;存储放在数据层。

规范存储

组件和仓库交换的是值对象,绝不交换原始 int 或字符串。

数量规范存储类型绝不存储
金额整数最小货币单位 + Currencyintdouble、格式化字符串
距离整数米intkm、英里
体积整数毫升int升、加仑
时长整数分钟(或 Duration)intdouble 表示的小时
时间戳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。

参考资料

评论

支持 Markdown