Qeasy Cloud
Get Started

5 个状态机 × 7+5+3+4+4 态:电商对账的状态机设计

· 许创贵· AI Financial Reconciliation· 16 views· 12 min read
状态机IncomePlan 7 态ExpensePlan 5 态BillRow 3 态状态迁移断言函数

电商对账 5 个状态机设计

摘要:电商对账系统里,状态机不是「字段约束」——它是业务流程的镜像。每条账单进什么状态、每个计划卡在什么阶段、每个异步任务什么时候超时,都直接决定了下游金蝶推凭证的成败。这篇文章不讲通用的 FSM / XState / Spring State Machine 理论,而是用 2026 年 7 月定稿、9 月实测的 23 个状态值,讲清楚三件事:① 为什么必须用 enum + 迁移表代替 string status,而不是 isCompleted: boolean 的逻辑堆砌;② 5 个状态机(3+4+7+5+4)的边界定义和迁移关系;③ 用一个 28 行的 assertXxxTransition 函数,把状态迁移断言压成一行代码就能拦截所有非法操作。

关键词:状态机、IncomePlan 7 态、ExpensePlan 5 态、BillRow.parseStatus 3 态、BillRow.reconcileStatus 4 态、JobTask.status 4 态、迁移表、断言函数

一、从 boolean 字段到状态机:财务系统的工程化拐点

2026 年 6 月某天,京东 POP 接入对账系统的第 4 个版本里出了一个 P0 级事故:财务在「收入对账已确认」的状态下手动点「重新对账」,结果原计划的物料明细和汇总字段没有清零,新一轮对账直接把上一次的 matchedSupplyIncome、unmatchedCount、totalDiffAmount 当作初值累加,写出一份看起来有数据、本质是 1 + 1 = 11 的对账结果。审计追溯时发现,原代码里只有一个 if (status === 'CONFIRMED') { return } 守卫——但用户的真实操作是「先退到 RECONCILED、再点开始对账」,而 RECONCILED → RECONCILING 这条迁移根本不存在。

这不是「少写一个 if」的事故——它是「用 boolean 字段表达状态」的固有灾难。三个字段 isParsed / isReconciled / isConfirmed 在布尔代数下能拼出 8 种组合,但其中 3 种(isParsed && !isReconciled && isConfirmed 等)是物理上不存在的状态。代码里到处写「if (isParsed && !isReconciled) { ... }」的守卫,本质是把业务规则的判断放在每一次调用现场,每一次新需求都会引入一个新的判断分支。状态机是唯一一个「业务规则即数据结构」的设计——迁移表写好后,所有非法迁移都会被一行断言函数拦下来。

把整套电商对账系统按这条原则盘点下来,5 个独立的状态机 × 23 个状态值覆盖了从「账单进来没」到「对账确认」到「推金蝶」的全链路:

状态机状态值角色
BillRow.parseStatus3 态(PENDING / PARSED / FAILED)解析沙箱的执行结果
BillRow.reconcileStatus4 态(PENDING / RECONCILED / CONFIRMED / NO_NEED)对账反写状态
IncomePlan.status7 态(PENDING/READY/RECONCILING/RECONCILED/CONFIRMED/FAILED/CANCELLED)收入对账计划生命周期
ExpensePlan.status5 态(PENDING/READY/CONFIRMED/FAILED/CANCELLED)费用对账计划生命周期
JobTask.status4 态(PENDING/RUNNING/SUCCESS/FAILED)+ 旁路 CANCELLED异步任务生命周期

状态机架构图:5 个状态机全景,含 BillRow.parseStatus 3 态、BillRow.reconcileStatus 4 态、IncomePlan 7 态、ExpensePlan 5 态、JobTask 4 态

5 个状态机不是拍脑袋定的——它们各自对应不同生命周期、不同操作主体、不同恢复策略的业务实体。BillRow 两个状态机是被动行级(被解析脚本 / 对账 Worker 写),IncomePlan / ExpensePlan 是用户主动操作(点开始 / 点确认)的头表,JobTask 是异步队列里的载体。一个常见的设计误区是把它们合并成「一个全局 status 字段」,但实际跑起来,用户对计划的「取消」不会影响已经在跑的 JobTask——它们必须各自独立迁移。

B 级干货 #1:状态机不是「字段约束」,是「业务规则的代码化」——本系统所有状态机迁移的实现只有 28 行(income-plan-status.ts)和 26 行(expense-plan-status.ts),核心是 Record<Status, Status[]> 迁移表 + assertXxxTransition(from, to) 断言函数。这套设计让产品经理能在 PR 评审时一眼看出「合规 ↔ 不合规」,而不是去翻业务代码。

二、28 行核心:迁移表 + 断言函数的双 8 行实现

apps/api/src/biz_reconciliation/income-plans/income-plan-status.ts 整个文件只有 28 行(包含 import 和注释),但它包含了 7 态状态机的全部定义:

typescript
const INCOME_PLAN_TRANSITIONS: Record<IncomePlanStatus, IncomePlanStatus[]> = {
  PENDING: ["READY", "FAILED", "CANCELLED"],
  READY: ["RECONCILING", "FAILED", "CANCELLED"],
  RECONCILING: ["RECONCILED", "FAILED"],
  RECONCILED: ["RECONCILING", "CONFIRMED", "CANCELLED"],
  CONFIRMED: [],
  FAILED: ["PENDING", "RECONCILING", "CANCELLED"],
  CANCELLED: [],
};

export function assertIncomePlanTransition(from: IncomePlanStatus, to: IncomePlanStatus): void {
  if (!INCOME_PLAN_TRANSITIONS[from].includes(to)) {
    throw new BadRequestException(`非法状态迁移: ${from} → ${to}`);
  }
}

读这段代码,7 个核心事实被一次性写进数据结构里:

  1. PENDING → READY:体行生成完成后由系统推进(自动);
  2. READY → RECONCILING:用户点开始对账(手动);
  3. RECONCILING → RECONCILED:Worker 完成所有体行核对(自动);
  4. RECONCILED → CONFIRMED:用户点整体确认(手动,终态);
  5. RECONCILED → RECONCILING:用户点重新对账,覆盖旧结果(手动,重跑);
  6. 任意非终态 → CANCELLED:用户点取消计划(手动,终态);
  7. FAILED → PENDING:系统重试入口(手动或自动)。

所有 Prisma 写操作都必须经过 assertXxxTransition——这是用 lint 规则 + 代码 review 把守的硬约束:prisma.incomePlan.update({ ... data: { status: 'X' } }) 这种裸写在 PR 阶段会被产品经理审计卡掉(product-manager-review skill 在 .opencode/skills/product-manager-review/SKILL.md 是 PR 前必跑)。如果想走这条路,可以参考轻易云智能对账系统这套「迁移表 + 断言函数」的实现方式——它把产品经理的合规审计和工程师的代码 review 用同一份数据结构对齐了,迁移表变了 = 状态机变了 = PR 必须合规范,不用口头约定。

ExpensePlan 的实现是同样的双 8 行,但只有 5 态——{ CONFIRMED: [], CANCELLED: [] } 是两个终态。v3 重构后费用侧删除了 RECONCILING(细节见 §四),数据上的差异是 READY: ["CONFIRMED", "FAILED", "CANCELLED"]——一次性推进、没有重跑。

三、IncomePlan 7 态:重新对账的迁移闭环

7 态里最有意思的是 RECONCILED → RECONCILING 这条反向迁移。它对应「重新对账」业务动作的实现:

收入对账 7 态状态机迁移流程图:PENDING→READY→RECONCILING→RECONCILED→CONFIRMED,含重新对账与取消分支

迁移表里的 RECONCILED: ["RECONCILING", "CONFIRMED", "CANCELLED"] 把三个动作并排放在一个数组里是有讲究的——它强制了「重新对账」和「整体确认」必须显式二选一。如果不做这个声明,UI 层的「重新对账」按钮就可能把已经 CONFIRMED 的计划误推回 RECONCILING,导致下游 BillRow.reconcileStatus = CONFIRMED 反写被破坏。B 级干货 #2:迁移表是给 UI 设计看的,不是给后端看的——前端组件 IncomePlanStatusBadge 只需直接读 INCOME_PLAN_TRANSITIONS 的 key 就能知道哪些状态可变,不需要写死业务规则。

具体到 reconciled → reconciling 的实现,apps/api/src/biz_reconciliation/income-plans/income-plans.service.ts::start() 做了三件事:

  1. 断言迁移合法性:assertIncomePlanTransition(currentStatus, "RECONCILING"),若当前不是 RECONCILED / FAILED 直接抛 400;
  2. 清空旧结果:覆盖式重跑——体行的 reconcileResult / diffReason / diffAmount / matchedSupplyIncome 字段逐条覆盖;物料明细先删后建(重跑失败行清旧明细,不残留);
  3. 汇总字段归零:matchedCount / unmatchedCount / totalDiffAmount / completedAt 启动时清零、对账完成后重写。

这对应到 2026 年 6 月那次 P0 事故——if (status === 'CONFIRMED') { return } 的守卫是错误的,因为它没考虑到「退到 RECONCILED 再重跑」的合法路径。引入状态机后,事故原因不可能再次出现——重跑路径在迁移表里是显式声明的,所有起点不是 RECONCILED / FAILED 的「重新对账」请求都会被断言拦截。

完整的「创建 → 体行生成 → 对账 → 重新对账 → 整体确认」五阶段流程图如下:

收入对账完整流程图:创建到确认

这张图把 7 态状态机的所有迁移与背后动作(JobTask 创建、BullMQ 入队、SupplyOrder 双码匹配、物料明细反写、BillRow 三件套反写)的关系画了出来。7 态不是「字典大小」问题,是「业务流程颗粒度」问题——把它压成 4 态(去掉 RECONCILING、合并 READY)看似简洁,但会让「重跑覆盖旧结果」和「取消任务」失去状态区分。

产品里实际跑出来的状态条样式是这样的:

收入对账管理:6 条 5+ 平台 IRP- 计划(状态徽章可见)

**这里能看到的「RECONCILING」「FAILED」「CONFIRMED」**这些标签不是装饰——它们是 7 态状态机在前端的可视化层。每个颜色对应一个 variant:warning(待处理)→ info(进行中)→ success(完成)→ danger(失败)→ neutral(取消)。注意前端的徽章组件 IncomePlanStatusBadge 完全不写业务逻辑,只读 Record<Status, Variant> 这种纯数据映射,业务规则集中在迁移表里,UI 只是它的镜像。

四、ExpensePlan 5 态:v3 简化的代价与收益

expense-plan-status.ts 同样 26 行,5 态里少了 IncomePlan 的 RECONCILING / RECONCILED 两个状态:

typescript
const EXPENSE_PLAN_TRANSITIONS: Record<ExpensePlanStatus, ExpensePlanStatus[]> = {
  PENDING: ["READY", "FAILED", "CANCELLED"],
  READY: ["CONFIRMED", "FAILED", "CANCELLED"],
  CONFIRMED: [],
  FAILED: ["PENDING", "CANCELLED"],
  CANCELLED: [],
};

这背后是 2026 年 7 月 v3 重构的重大变更——费用对账不再支持「逐条确认 / 批量确认」,改为计划级整体确认:

typescript
// v3 重大变更:ExpensePlanItem.isUserConfirmed 字段已废弃
// 删除原 ExpensePlanItem 模型,引入三层架构:
//   ExpenseReconciliationPlan(头)
//   → ExpenseAggregation(聚合行:五元聚合键)
//   → ExpenseAllocationItem(字表:订单公摊明细)

5 态的实现里没有 RECONCILING——因为费用对账没有「逐行匹配供应链」的过程。聚合行(ExpenseAggregation)是按核算项目 × 核算单位 × 类别 × 方向 × 净额 5 元键聚合的池子,不与单笔订单一一对应。等聚合行全部生成完毕后直接 PENDING → READY,再由用户一次性推 READY → CONFIRMED:

费用对账完整流程图:聚合到反写(5 态 ready → confirmed 直通无 reconciling 过渡)

注:这张流程图里能看到费用侧 5 态的实际跑法——READY 之后没有「reconciling」过渡态,直接到 CONFIRMED(终态)。中间夹着「run-allocate」动作触发 JobTask(EXPENSE_ALLOCATE) 执行沙箱分摊脚本,但沙箱分摊不影响 ExpensePlan.status——它只往 ExpenseAllocationItem 写字表,反写 IncomePlanItem 的 allocatedFeeAmount 字段,分摊失败不影响计划状态。

B 级干货 #3:少一个状态 = 少一类事故——v3 删除 RECONCILING 后,事故率直接降了一个量级。原 7 态模式下,费用计划会卡在「聚合行已生成但沙箱分摊跑不完」的状态里,财务无法知道「是聚合行有问题还是沙箱有问题」。5 态模式下,问题被强制分流到「聚合行未生成(PENDING 卡住)」或「沙箱分摊失败(记在 JobTask.status = FAILED)」两个独立可观测点。这就是「状态机是业务流程的镜像」的最直接证据——业务流程变简单,状态机也变简单,可观测性反而变好。

如果想走「少状态 = 少事故」这条路,ExpensePlan 5 态的设计哲学值得抄:把异步副作用(沙箱分摊)从计划状态机里剥离出去,让计划状态机的语义只剩「聚合行是否就绪、用户是否确认」两件事。

五、BillRow 的三件套:3+4+2 = 三个小状态机的精密分工

每条 BillRow 上有三个状态字段,它们的语义完全不同:

字段状态值写入者写时点
parseStatusPENDING / PARSED / FAILED解析沙箱解析脚本跑完后
reconcileStatusPENDING / RECONCILED / CONFIRMED / NO_NEED对账 Worker / 用户确认对账完成后 + 整体确认时
reconcileResultSUCCESS / FAILURE对账沙箱对账脚本跑完后

这三个字段的关系可以理解成「双阶段 + 一标签」:

阶段 1:parseStatus = PARSED(解析完成)
                                   ↓
阶段 2:reconcileStatus = RECONCILED + reconcileResult = SUCCESS/FAILURE(对账完成)
                                   ↓
终态:reconcileStatus = CONFIRMED(整体确认)

关键工程点:阶段 2 的两个字段是同时写入的——reconcileStatus = RECONCILED 是「计划层状态机推进到 RECONCILED」时反写到所有 BillRow 的;reconcileResult 是「每条体行」的对账结果(SUCCESS / FAILURE),与 reconcileStatus 不是同一维度。反直觉的是:一个 BillRow 可以是 reconcileStatus = RECONCILED && reconcileResult = FAILURE——这表示「这条记录参与了对账、对账结果是失败,但整笔计划的阶段是已对账」。

reconcileResult = SUCCESS / FAILURE 两个值是 23 状态值里最容易被误判的——它不是状态机,而是对账结果的机读标签。23 个差异标签(diffReason 业务标签码)从这里开始分支(OTHER_AMOUNT_DIFF / SUPPLY_PRICE_DIFF / QUANTITY_DIFF 等),推到下一层就是「处理建议」(diffDisposalSuggestion)。

reconcileStatus 4 态里最有意思的是 NO_NEED——它表示「这条账单不需要参与对账」(典型场景:账期内已经有供应链订单匹配,过剩的 BillRow 标记为 NO_NEED)。这种「不参与」的状态在迁移表里是一个终态——PENDING → NO_NEED 迁移只能在初始化时触发,一旦置 NO_NEED 不可再变。

BillRow.parseStatus 3 态是 5 个状态机里最简单的:

PENDING → PARSED(解析成功)
PENDING → FAILED(解析失败:解析后双零 / 脚本抛错)
PARSED → FAILED(v3 起允许「解析后双零判失败」的反向修正)

迁移表里没有 PARSED → PENDING 的反向迁移——一旦解析成功就视为已落定,失败由专门的 abnormalReason 字段承载,不会把 PARSED 退回到 PENDING。

六、JobTask 4 态:异步任务状态机的旁路取消

JobTask.status 是 5 个状态机里最容易跑偏的——它的 4 态(PENDING / RUNNING / SUCCESS / FAILED)+ 旁路 CANCELLED 不是设计出来「故意多一个态」,而是异步执行的固有复杂度:

typescript
// job-tasks.service.ts::stop()
if (task.status === "PENDING") {
  await this.removeQueuedJob(id);  // 从 BullMQ 队列里移除
}
const updated = await this.prisma.jobTask.update({
  where: { id }, data: { status: "CANCELLED", finishedAt: new Date() }
});

if (task.status === "RUNNING" && task.type === "PARSE") {
  abortParseJob(id);  // PARSE 任务同进程内立即 abort 沙箱执行
}

异步任务的 CANCELLED 是「手动停止」语义——不是业务流程的自然终态。PENDING 时还能从队列里移除,RUNNING 时只能置 CANCELLED 作为标记、由 Worker 批次检查点识别后退出(跨实例 / 重启兜底)。

这与收入对账的「重新对账」有完全不同的语义:

  • IncomePlan.status = CANCELLED 是用户主动放弃这个对账计划,是业务的计划级取消;
  • JobTask.status = CANCELLED 是用户停止这条异步任务的运行,但它对应的工作可能已经写入了部分数据(比如解析脚本已经解析了 200 行中的 150 行)。

迁移表里 JobTask: PENDING/RUNNING → CANCELLED 与 FAILED 是两个完全独立的终态:

typescript
// 手动停止
CANCELLED:finishedAt = now, errorMessage = "手动停止"
// 沙箱抛错
FAILED:finishedAt = now, errorMessage = err.message

前端展示时虽然同色(neutral),但审计日志里它们是两类不同的事件——这是「同色不同义」的反直觉设计,但符合状态机的「迁移即语义」原则。

七、跨状态机穿透:从 IncomePlan 到 BillRow 的反写链

5 个状态机不是孤立运行的——它们之间有反写关系。最重要的一条是:

IncomePlan 整体确认(RECONCILED → CONFIRMED)时,反向更新所有 BillRow 的 reconcile_status = CONFIRMED。

这条反写链是电商对账「推金蝶」的硬前置——只有 BillRow.reconcileStatus = CONFIRMED 的行才能进入 transform_documents 批次生成(集成转换第四类沙箱),最终推到金蝶云星空的财务应收单 / 应付单里。任何「计划层已确认但行层未确认」的脱节,都会让集成转换脚本读到「看似有数据但没确认」的空集。

IncomePlan.status = CONFIRMED(用户点确认)
    → 反向更新所有 BillRow:
        ├─ reconcile_status = CONFIRMED
        ├─ confirmed_at = now
    → 联级触发: BillRow 可进入 transform_documents 批次
        → 集成转换沙箱生成金蝶单据
        → 推送到 KingdeeCloudGalaxy

迁移表的精细度直接决定了反写的精确度。如果 REC conciliated → CONFIRMED 不止迁移一条主状态记录,而要同时回写 N 条 BillRow,那么这 N 条 BillRow 的原始 reconcileStatus 必须在迁移之前是 RECONCILED(不能是 PENDING / NO_NEED)。这就是迁移表与反写链的约束关系——状态机不是孤立运行,它要保证「前置状态合法、后置状态一致」。

合规审计时,这种 5 状态机穿透链给出了一个全链路可追溯的优势:审计师想追「这笔金蝶应收单是从哪条京东订单来的」,可以反推回溯:transform_documents.head.aggregateId → BillRow.reconcileStatus = CONFIRMED → IncomePlanItem.direction → IncomePlan.status = CONFIRMED → 顶头进入用户操作日志。整个查询用不到 5 张表的 join,但每一步都有状态机给出的合法性保证。

轻量级的合规审计闭环在这里:轻易云智能对账系统的 5 个状态机 × 23 状态值,配合 plan_source_relations 5 字段最小化桥表,让 SOX 404 抽样审计的「追一笔账从导入到金蝶」查询只需 3-4 个 index scan,远比传统系统的「全表 join + 业务上下文推断」要快得多。

八、收尾:后端架构师的 3 条 takeaway

把这 5 个状态机 × 23 状态值的全景画出来后,三个工程结论就有了具体刻度:

  1. 状态机不是「字段约束」——它是「业务规则的数据结构」。迁移表写好后,所有非法迁移都会被一行断言函数拦截;产品经理能在 PR 评审时一眼看出「合规 ↔ 不合规」,不需要去翻业务代码。28 行实现 vs 800 行 if-else 守卫,是直接的数量级收益。

  2. 少一个状态 = 少一类事故。ExpensePlan 5 态(无 RECONCILING)相对 IncomePlan 7 态的简化,把「沙箱分摊跑不完」的卡死状态从计划里剥离出去,问题分流到 JobTask.status 的独立可观测点。业务流程变简单,状态机也变简单,可观测性反而变好。

  3. 5 个状态机不是「为了拆而拆」——它们各自对应不同生命周期、不同操作主体。如果把它们合并成「一个全局 status 字段」,就会失去「用户对计划的取消不会影响已经在跑的 JobTask」这类语义保证。状态机的拆分依据是「这个实体有独立的生命周期 + 操作主体 + 恢复策略」。

回到后端架构师视角的最终判断标准——「状态机是业务流程的镜像,而不是字段装饰」。23 个状态值的工程深度,决定了从「账单进来没」到「对账确认」到「推金蝶」的每一步都有合法性保证。这套设计语言不带任何过度抽象,把产品逻辑的复杂度从代码里搬到了迁移表里——而迁移表是 28 行就能 review 完的纯数据结构。


参考 skill:

  • .opencode/skills/income-reconciliation-development/SKILL.md —— 双子对账计划架构、7 态 / 5 态状态机迁移详解(含 2026-07-22 重新对账迁移规则)
  • .opencode/skills/product-manager-review/SKILL.md —— 业务规范合规审计,状态机迁移的 PR 前必跑关卡

本篇配图(来自架构图库 + 产品演示账号截图):

  1. arch-008 状态机架构图:5 个状态机全景 —— 开篇全景
  2. proc-008 收入对账 7 态状态机迁移流程图 —— 主体段讲重新对账迁移
  3. proc-003 收入对账完整流程图:创建到确认 —— 7 态与 4 个阶段动作的关系
  4. proc-004 费用对账完整流程图:聚合到反写 —— ExpensePlan 5 态的实证
  5. 14 收入对账管理:6 条 5+ 平台 IRP- 计划 —— 产品里实际跑出来的状态徽章
Original content. Please credit the source when reposting: https://www.qeasy.cloud/insights/reconciliation/2-3-3-five-state-machines-23-states-design

Comments