GoWind ERP 拆解:一个 ERP 里的三种一致性等级
本文回答一个问题:"审批完的采购单自动生成应付、收货自动回写库存、低库存自动起草补货单"------同一句"数据全打通",为什么有的链路用硬事务、有的用软失败、有的用事务后 defer?
GoWind ERP(采购/库存/财务三大模块)的 README 把"单据驱动单据"当核心卖点,而代码里真正值得学的是它对一致性等级的分层:不同链路按"失败的代价"选了三种不同的机制------硬同事务、软失败人工补、单体 SAGA 缝。这篇文章把这三种机制各拆到底,再补一刀贯穿全库的并发武器:SQL 条件更新。
一、硬同事务:收货校验的七合一
全仓库最有分量的函数是 stock_picking_service.go 的 Validate()(911 行文件的核心 450 行),注释直接声明设计目标:
Validate 校验拣货单:在单事务内对每个 CONFIRMED move 创建 move-line、更新 stock_quant(source 减、dest 加)、若入库则回写采购收货量(借鉴 Odoo button_validate / _action_done)。任一步骤失败 → 整事务回滚,无补偿、无不一致窗口。
单事务内完成七类回写:创建 move-line 执行记录(含批次 FEFO 拆分)→ source 位置扣减(防负)→ dest 位置加库存并更新加权平均成本 → move 状态置 DONE → 总账过账 (收货=借库存商品/贷应付账款)→ 回写采购明细的 received_quantity(防超收),收齐自动完结采购单 → 销售侧镜像回写 fulfilled_quantity。
选硬事务的理由写在选择里:这些步骤要么全部成立、要么全部不成立------库存动了但总账没过,是财务事故;总账过了但库存没动,是货账不符。这类"原子性就是正确性本身"的链路,不值得引入任何补偿机制,一个事务解决。
defer LIFO 的提交顺序讲究
Validate 里有一个精巧的细节:补货评估的 defer 先 注册(所以最后执行),事务完成函数 FinishTx 后 注册(所以先执行)------利用 Go defer 的 LIFO 语义,保证"事务先提交,补货评估再读已提交数据"。defer 注册顺序即执行时序,这类顺序依赖必须靠注释钉死,否则下一个重构的人随手调换两行就会引入"评估读到未提交数据"的幽灵 bug。
二、软失败:审批完成的三路联动
与收货相反,采购单审批通过 时的三路联动------生成应付单、生成入库拣货单、(低库存场景)生成补货审批单------全部是同步调用、独立落库、失败只记日志:
go
// 财务联动:采购单获批即生成全额应付单(手工建账之外的自动来源)。
// 生成失败不影响审批结果,仅记录(可手工补建)。
为什么这里不用硬事务?因为失败代价完全不同 :应付单漏了,财务看得见、手工补一张即可;而审批本身是用户强感知的主操作,让"审批成功"因为一张衍生单据的生成失败而整体失败,是把可补偿的小问题升级成不可补偿的大问题。三路联动的可恢复性靠来源追溯 保证:应付单带 po_ref 字段(约定 PURCHASE_ORDER:{id},建了租户+来源索引),采购退货时正是靠它反查应付单做财务冲抵------溯源字段是软失败链路的自愈基础。
这个"溯源字段 + 人工补建"的兜底比它看起来重要:软失败链路的可用性不取决于失败率多低,取决于失败之后能不能被找到。
三、单体 SAGA 缝:低库存 → 补货的反应式链路
第三条链路最有趣:出库导致库存低于阈值 → 自动起草补货审批单。实现是一个 130 行的 inventory_saga_seam.go,文件注释自我定位极其诚实:
sagaSeam 库存→采购的反应缝("SAGA"在本单体架构下的真实形态):出库后低库存 → 幂等评估 → 补货建议审批单 → 人审后生成草稿采购单。
- 评估失败不阻塞出库......最终一致靠"重评估"而非"重放";
- 幂等靠双重在途检查......极端并发下仍可能偶发重复建议,由审批人去重;
- 采购决策保留人机环:不自动下采购单。
机制拆开是三层:
- 触发时机 :不是定时扫描、不是消息队列------出库腿扣减成功后在事务里收集
stockEvent,事务提交后 由 defer 逐个调notifyProcurement(呼应第一节的 LIFO 顺序讲究:评估永远读已提交数据); - 幂等 :双重在途检查------已有未收满的在途采购单?已有未决的补货审批单?任一存在就不重复建议。注释坦承极端并发下仍可能偶发重复,兜底是审批人去重------把机器防不住的极小概率交给流程里天然存在的人;
- 人机环 :审批通过后才由审批服务调采购服务生成草稿 采购单(Remark 标注"低库存自动补货草稿,请完善后提交"),供应商取该 SKU 最近采购来源、无历史则不建单------自动起草但不自动提交。
还有一句值得引用的架构回应:BFF 里引了 DTM 客户端,但核心单据链路并未使用 DTM 补偿------单体内"源事务 + 提交后 defer + 幂等评估"就是 SAGA 在这个规模下的真实形态,不需要为它引入分布式事务中间件。
四、贯穿的武器:SQL 条件更新
三种机制里所有的并发防护,最后都落在同一种原语上------带条件的 UPDATE,对当前值求值。几个代表:
防超付 + 状态推导合一(应付付款,单条 UPDATE 同时完成累加、防超付、状态推导):
go
// 防超付:当前已付 + 本次 ≤ 总额(对当前值求值,非读到的旧值)
Where(sql.ExprP("paid_amount + %d <= amount", amount)).
AddPaidAmount(amount)
// 状态按累加后的终值推导(CASE 在 DB 内对累加结果求值)
Modify(func(u *sql.UpdateBuilder) {
u.Set(payable.FieldStatus, sql.Expr(
"CASE WHEN paid_amount + %d >= amount THEN 'SETTLED' ELSE 'PARTIAL' END", amount))
})
收货闸门 ("闸门"不是收货要审批,而是只有 APPROVED 状态的采购单其收货回写才被放行):条件计数实现,gateN != 1 统一 409------注释点明这"消除状态枚举泄漏的同时关闭主要收货绕过路径"。配合入口侧双保险:入库拣货单禁止手工创建(只能由审批链自动产生)。
防超收 :received + 本次 <= ordered 对当前值求值,超收 0 行受影响即 409;收齐则事务内条件迁移 APPROVED→COMPLETED。采购退货反向:守卫 received − qty >= 0、闸门放宽为 APPROVED|COMPLETED、减空则重开为 APPROVED(状态机里的回边)。
防超卖 :库存扣减 WHERE quantity + delta >= 0,0 行受影响 → "insufficient stock or stock_quant changed concurrently"。回归测试是 50 个 goroutine 各自事务里 +1 ,断言终值精确等于初始+N(内存 SQLite 跑,SetMaxOpenConns(1) 的坑也写在注释里);另有一个"空表 SELECT SUM 返回 NULL 行导致看板 500"的审计回归。
这批写法的共同点:"读-判断-写"压缩成一条对当前值求值的 UPDATE,冲突即 0 行受影响,幂等性顺带获得(重复执行 0 行无害)。ERP 里这类写法出现了七处以上(防负库存/防超收/防超付/双降冲抵/乐观状态机/审批级进/闸门计数),堪称单体内并发控制的标准弹药。
五、账龄报表:口径即正确性
财务侧值得单独一节的是应付账龄(AgingReport),README 把"账龄未清余额口径"写进关键一致性清单,因为这里出过两个真 bug------aging_test.go 的注释记录在案:
- The per-bucket total must be the sum of OUTSTANDING balances (amount − paid_amount), NOT the sum of face-value amounts. The old code summed
amount, so a partially-paid (PARTIAL) row inflated the bucket.- The per-bucket count must ACCUMULATE the number of rows... The old code overwrote Count to 1 each iteration.
口径三要素:只统计未结清单据 (PENDING/PARTIAL,已结清不进账龄);按未清余额而非面值聚合 (SUM(amount − paid_amount),部分付款行按面值计入会高估负债);按到期日而非单据日分桶 (overdue / 0-7 / 8-30 / 31-90 / 90+ / 无到期日单独一桶,NULL 单独处理)。测试用两张部分付款的单据断言"2200/2 而不是 3000/1"------报表口径的错误不会报错,只会悄悄误导付款决策,这正是它值得一个专门测试的原因。
配套的金额纪律也一并记录:金额全部用"分"存 int64 并配溢出守卫(addChecked/mulChecked,表驱动测试覆盖边界);冲正没有独立的"红字冲正单"实体,而是"append-only 流水 + 反向单据 + 财务冲抵"约定(付款一经记录不可改删,记错走反向付款;退货冲抵是"信用票据式双降":amount 与 paid_amount 同降、状态按冲抵后终值逆向推导);总账过账强制借贷平衡校验,科目表仅 8 个科目硬编码------财务正确性的大头在纪律而不在功能。
六、结语
三种一致性等级的选型逻辑,收敛成一张表:
| 链路 | 机制 | 判据 | 失败形态 |
|---|---|---|---|
| 收货回写(库存+总账+防超收) | 硬同事务 | 原子性即正确性,部分成功是事故 | 整体回滚 |
| 审批通过的衍生单据 | 软失败 + 溯源字段 | 失败可人工补建,主操作不该被拖累 | 日志可见,人工补 |
| 低库存补货建议 | 事务后 defer + 幂等评估 + 人审 | 建议类内容,重复无害(人去重) | 不阻塞出库,重评估自愈 |
加上贯穿的 SQL 条件更新(并发防护)与账龄口径测试(报表正确性),GoWind ERP 给出的可迁移经验是:一致性不是越强越好,是按"失败的代价与可发现性"分级;而无论哪一级,并发正确性的底线都落在"对当前值求值的条件 UPDATE"上------这个原语便宜到不值得为它引入任何中间件。
仓库(核心链路在 backend/app/core/service/internal/service/stock_picking_service.go、purchase_order_service.go、inventory_saga_seam.go):
- GitHub:github.com/tx7do/go-wi...
- Gitee:gitee.com/tx7do/go-wi...