5 个状态机 × 7+5+3+4+4 态:电商对账的状态机设计
电商对账 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.parseStatus | 3 态(PENDING / PARSED / FAILED) | 解析沙箱的执行结果 |
BillRow.reconcileStatus | 4 态(PENDING / RECONCILED / CONFIRMED / NO_NEED) | 对账反写状态 |
IncomePlan.status | 7 态(PENDING/READY/RECONCILING/RECONCILED/CONFIRMED/FAILED/CANCELLED) | 收入对账计划生命周期 |
ExpensePlan.status | 5 态(PENDING/READY/CONFIRMED/FAILED/CANCELLED) | 费用对账计划生命周期 |
JobTask.status | 4 态(PENDING/RUNNING/SUCCESS/FAILED)+ 旁路 CANCELLED | 异步任务生命周期 |

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 态状态机的全部定义:
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 个核心事实被一次性写进数据结构里:
- PENDING → READY:体行生成完成后由系统推进(自动);
- READY → RECONCILING:用户点开始对账(手动);
- RECONCILING → RECONCILED:Worker 完成所有体行核对(自动);
- RECONCILED → CONFIRMED:用户点整体确认(手动,终态);
- RECONCILED → RECONCILING:用户点重新对账,覆盖旧结果(手动,重跑);
- 任意非终态 → CANCELLED:用户点取消计划(手动,终态);
- 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 这条反向迁移。它对应「重新对账」业务动作的实现:

迁移表里的 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() 做了三件事:
- 断言迁移合法性:
assertIncomePlanTransition(currentStatus, "RECONCILING"),若当前不是 RECONCILED / FAILED 直接抛 400; - 清空旧结果:覆盖式重跑——体行的
reconcileResult / diffReason / diffAmount / matchedSupplyIncome字段逐条覆盖;物料明细先删后建(重跑失败行清旧明细,不残留); - 汇总字段归零:
matchedCount / unmatchedCount / totalDiffAmount / completedAt启动时清零、对账完成后重写。
这对应到 2026 年 6 月那次 P0 事故——if (status === 'CONFIRMED') { return } 的守卫是错误的,因为它没考虑到「退到 RECONCILED 再重跑」的合法路径。引入状态机后,事故原因不可能再次出现——重跑路径在迁移表里是显式声明的,所有起点不是 RECONCILED / FAILED 的「重新对账」请求都会被断言拦截。
完整的「创建 → 体行生成 → 对账 → 重新对账 → 整体确认」五阶段流程图如下:

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

**这里能看到的「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 两个状态:
const EXPENSE_PLAN_TRANSITIONS: Record<ExpensePlanStatus, ExpensePlanStatus[]> = {
PENDING: ["READY", "FAILED", "CANCELLED"],
READY: ["CONFIRMED", "FAILED", "CANCELLED"],
CONFIRMED: [],
FAILED: ["PENDING", "CANCELLED"],
CANCELLED: [],
};
这背后是 2026 年 7 月 v3 重构的重大变更——费用对账不再支持「逐条确认 / 批量确认」,改为计划级整体确认:
// v3 重大变更:ExpensePlanItem.isUserConfirmed 字段已废弃
// 删除原 ExpensePlanItem 模型,引入三层架构:
// ExpenseReconciliationPlan(头)
// → ExpenseAggregation(聚合行:五元聚合键)
// → ExpenseAllocationItem(字表:订单公摊明细)
5 态的实现里没有 RECONCILING——因为费用对账没有「逐行匹配供应链」的过程。聚合行(ExpenseAggregation)是按核算项目 × 核算单位 × 类别 × 方向 × 净额 5 元键聚合的池子,不与单笔订单一一对应。等聚合行全部生成完毕后直接 PENDING → READY,再由用户一次性推 READY → CONFIRMED:

注:这张流程图里能看到费用侧 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 上有三个状态字段,它们的语义完全不同:
| 字段 | 状态值 | 写入者 | 写时点 |
|---|---|---|---|
parseStatus | PENDING / PARSED / FAILED | 解析沙箱 | 解析脚本跑完后 |
reconcileStatus | PENDING / RECONCILED / CONFIRMED / NO_NEED | 对账 Worker / 用户确认 | 对账完成后 + 整体确认时 |
reconcileResult | SUCCESS / 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 不是设计出来「故意多一个态」,而是异步执行的固有复杂度:
// 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 是两个完全独立的终态:
// 手动停止
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_relations5 字段最小化桥表,让 SOX 404 抽样审计的「追一笔账从导入到金蝶」查询只需 3-4 个 index scan,远比传统系统的「全表 join + 业务上下文推断」要快得多。
八、收尾:后端架构师的 3 条 takeaway
把这 5 个状态机 × 23 状态值的全景画出来后,三个工程结论就有了具体刻度:
-
状态机不是「字段约束」——它是「业务规则的数据结构」。迁移表写好后,所有非法迁移都会被一行断言函数拦截;产品经理能在 PR 评审时一眼看出「合规 ↔ 不合规」,不需要去翻业务代码。28 行实现 vs 800 行 if-else 守卫,是直接的数量级收益。
-
少一个状态 = 少一类事故。ExpensePlan 5 态(无 RECONCILING)相对 IncomePlan 7 态的简化,把「沙箱分摊跑不完」的卡死状态从计划里剥离出去,问题分流到 JobTask.status 的独立可观测点。业务流程变简单,状态机也变简单,可观测性反而变好。
-
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 前必跑关卡
本篇配图(来自架构图库 + 产品演示账号截图):
arch-008状态机架构图:5 个状态机全景 —— 开篇全景proc-008收入对账 7 态状态机迁移流程图 —— 主体段讲重新对账迁移proc-003收入对账完整流程图:创建到确认 —— 7 态与 4 个阶段动作的关系proc-004费用对账完整流程图:聚合到反写 —— ExpensePlan 5 态的实证14收入对账管理:6 条 5+ 平台 IRP- 计划 —— 产品里实际跑出来的状态徽章