期间与账务模型
事件流回答「发生了什么」,账务回答「某时点确定是多少」——后者要靠投影得到,不能替代前者。
为什么需要两条时间轴
传统系统只有一条时间:记录时间。但账务必须回答「这笔业务发生在哪一期」, 审计必须回答「我们是什么时候知道的」。
两者不等是常态,也是跨期调整的根源:
业务发生 2026-09-28
系统记录 2026-10-03 ← 隔了一个月才录如果只有一条时间,跨期归属就只能靠猜。所以拆成两条:
| 轴 | 字段 | 用途 |
|---|---|---|
| 业务发生 | occurredAt | 归属期间、余额计算 |
| 系统记录 | at | 审计「何时知道」、复放顺序 |
并且标明时间来源的可信度:
| 值 | 含义 | 后果 |
|---|---|---|
declared | 调用方明确声明 | 可用于账务 |
source | 来自外部系统 | 可用于账务 |
inferred | 未提供,回退为记录时间 | 不能进被重开过的期间 |
最后一条是硬约束:重开意味着「账已经结过,现在补记」,补记必须知道真实发生时间。
为什么是「按单结」而不是「自然月」
一单生意常常跨月:9 月下单、10 月交付、11 月才结算。
如果按自然月归集,这一单会被拆到三个期间,每次都要做跨期调整。 所以默认按单开期间:
periodId = `${scope}:${refId}`
order:o-123 / task:t-123 ← 按单
calendar:2026-10 ← 自然月汇总(只读,不参与门禁)结算那一刻就出分录,不攒到月末。自然月视图只是把这些分录按发生时间再分桶一次, 只读、可重算、不影响权威数据。
为什么门禁在存储层
「已关闭的期间不能写」这条规则,如果放在应用层:
typescript
// ❌ 换个调用入口就绕过了
if (period.status === 'closed') throw new Error('期间已关闭');
await state.write(...);所以它被放进唯一的写入口:
StateService.writeMany()
→ 域声明缓存
→ PeriodGuard.assertWritable() ← 这里
→ prepareWrite()三条细节:
| 规则 | 为什么 |
|---|---|
只看 /periods/{id}/... 子路径 | 根路径也拦的话,close() 之后无法 reopen(),自己锁死 |
| 关闭后子路径一律 409 | 不允许「自动挪到下一期」——自动挪会掩盖错误 |
已重开过的期间拒绝 inferred | 补记必须知道真实发生时间 |
为什么只做四个科目
完整会计体系有收入、成本、费用、税、资产、负债……那是财务软件的领域。
平台只需要回答一件事:托管池里的钱怎么分。
| 科目 | 含义 |
|---|---|
escrow_held | 托管中、尚未归属的资金 |
payable_supplier | 应付供给方 |
payable_platform | 应付平台服务费 |
refundable_buyer | 应退买方 |
四个科目足够让每一笔结算可入账、可核对、可追责。再往上做,成本会指数增长, 而收益不在当前的定位里。
分录为什么是「结构保证」的
借 escrow_held amount
贷 payable_supplier toAgent
贷 refundable_buyer toBuyer
贷 payable_platform toPlatform托管层已经保证了 toAgent + toBuyer + toPlatform === amount。 所以借贷平衡不是靠记账代码自觉,而是上游不变量的直接结果。
这也是为什么记账挂在 settle() 这个唯一出口——所有结算路径都会经过它。
七条不变量
| # | 不变量 |
|---|---|
| I1 | 每笔分录 Σdebit === Σcredit |
| I2 | 期间内 Σdebit === Σcredit |
| I3 | Σ结算分录 === 托管金额 |
| I4 | 已关闭期间不接受新分录 |
| I5 | 跨期调整必须指向已关闭期间 |
| I6 | 分录不可修改,只能冲销 |
| I7 | 单笔分录币种一致 |
边界
| 不做 | 原因 |
|---|---|
| 通用会计引擎(多准则、税务) | 会变成财务软件,与定位冲突 |
| 多币种换算与重估 | 需要真实跨境单才能定口径 |
| 报表与 BI | 状态层可直接出数,不建分析系统 |
| 银行直连 | 接真钱是独立决策 |
继续读
- 模块用法与接口 → Accounting 期间与账务
- 托管如何产生结算 → 履约与托管
- 四类事实形态 → 域模型