资讯动态

REA领域建模:用统一事件表解决库存、财务与对账难题

发布时间:2026/10/20 21:03:10 来源:尧图企业网站定制
如果你做过进销存、电商、供应链或者任何带账单的系统大概率被同一种痛折磨过库存表记一套流水表记一套财务凭证又记一套。我最近在某个内部项目里重构了订单、库存、财务三个模块项目代号就叫 REA。REA 全称是 Resource-Event-Agent翻译过来就是“资源、事件、主体”。整套设计最后做到在一个事务里完成库存变动、财务记账、承诺更新三件事对账直接查事件表不再到处 join。这篇文章会把建模思路、表结构、核心代码、踩坑实录和排查方法完整写出来给正在做业务系统重构的人一个能直接参考的样本。不管你是后端开发、架构师还是刚开始接触领域建模的同学都可以从这套思路里拿到能落地的方案。1. 老方案为什么让人抓狂一笔销售订单对出三套账1.1 传统台账模型的“各自为政”传统做法一般是三张核心表库存余额表、流水明细表、会计凭证表。订单模块在库存表上做预占和扣减供应链模块把出库记录写进流水财务模块再从流水里捞数据生成凭证。看起来每个模块都有自己的依据但问题在于同一笔销售订单在这三张表里表达的时间点、数量和状态都可能不一致。订单说“已下单”库存表显示“已锁定未扣减”财务凭证可能还没影等货发出去了库存表扣了流水也有记录但凭证因为科目配置错误一直没生成。对账的时候就必须把三套数据拉出来做各种比对字段含义对不上口径不统一最后还得靠人肉解释。这个状态在系统规模小的时候还能勉强忍受一旦订单量大、促销活动多三套账之间的差异会膨胀到根本无法手工核对。1.2 一次典型的对账风波我在项目里遇到过最典型的一次对账仓库反馈某商品账面库存还有 30 件但实际仓库只找到 25 件财务那边又显示这批商品已经全部结转成本。三边数据没有一边对得上。查到最后发现问题出在“锁定库存”和“实际扣减”被做成了两套独立字段某个异常流程把锁定库存释放了但实际扣减事件没有补写流水也就没有这条记录。这个教训让我意识到真正缺的不是一个更好的“账本”而是一个统一的业务事实记录层。所谓业务事实就是“某商品在某个时间点从哪个主体流向了哪个主体”而不是一堆状态字段拼出来的结果。我们当时花了很多时间修库存表、改流水状态但问题总是换个姿势反复出现就是因为没有一个模块认真回答了“到底发生了什么”。1.3 换一种思路让事件成为唯一事实REA 模式的核心是把资源、事件、主体拆开。业务动作发生的时候就生成一条不可变的事件库存余额只是事件累加的结果财务凭证也只是事件的可视化。这样所有模块都不再各自维护状态而是消费同一条事件。谁想对账直接按资源维度查询事件汇总结果就是账。这个思路看起来简单但真落地的时候建模细节和工程细节比预想多得多。比如事件表要不要保留余额字段、承诺怎么建模、财务凭证如何自动生成、并发扣减怎么防超卖每一项都需要仔细设计。后面几个章节我会把这些内容全部拆开讲清楚。2. REA 建模思路资源、事件、主体怎么理解2.1 三个概念的生活化解释资源是系统中有价值、可控制、会增减的东西。库存商品、现金、应收账款、采购预算都是资源。事件是让资源发生变化的一次实际发生比如采购入库、销售发货、收款、付款。它必须包含资源、数量、方向、时间、参与主体。主体是参与事件的角色客户、供应商、仓库管理员、操作员都算主体。用生活里的话说你把快递交给客户包裹是资源“签收”是事件你和客户是两个主体。这个动作发生后你的快递资源减少客户的快递资源增加这就是一条完整的经济事实。REA 的全部含义就是把这些经济事实结构化让它能被系统记录、校验、审计和统计。理解 REA 有一个关键点事件是真实的、已经发生的动作单据只是对事件的描述和入口。订单不是事实订单只是“承诺要发生某个事件”的载体真正改变库存、改变债权债务的是后续的经济事件。很多系统把订单当成了业务事实于是订单状态一变所有模块都要跟着变维护成本自然居高不下。2.2 为什么“单据优先”不如“事件优先”传统建模往往先画订单表、入库单表、出库单表然后围绕单据状态设计接口。单据一旦升级或扩展状态机就会膨胀因为每新增一种单据类型就要新增一套状态逻辑。REA 把重心放到事件上单据只是事件的聚合视图。一张销售订单对应“承诺要发货事件”“发货事件”“应收确认事件”“收款事件”等多个事件订单状态完全可以从这些事件推导出来不需要额外维护一个 order_status。虽然代码里可能仍然需要订单表但订单表的定位从“事实”降级成“业务入口”。下单接口进来只负责创建承诺类的记录发货接口进来生成发货事件收款接口进来生成收款事件。业务状态不再散落在多个模块里而是统一由事件推演得出。这个转变带来的最直接好处是新需求来了往往只需要新增一种事件类型而不是改三套状态机。2.3 事件之间的对偶关系REA 特别强调事件之间的对偶关系。一次销售业务可以拆成“交付商品事件”和“收取货款事件”两个半边二者通过 event_link 配对。交付商品让库存资源减少收取货款让现金或应收资源增加两个事件加在一起才构成一次完整的经济交换。这个配对会让财务处理变得非常自然交付商品生成成本凭证收取货款生成收入凭证两张凭证通过共同的对偶 key 关联在一起。审计人员追问“这笔收入对应哪次发货”时只需要从事件对偶关系链上查不用翻好几个模块。对偶关系也让“一笔业务漏掉了某半边”变得更容易发现因为事件链要求双方必须存在且方向相反。2.4 承诺层把“将来会发生”也纳入模型只有已经发生的事件还不够业务上大量存在“预定”“预留”“在途”的情况。REA 的扩展模型里有 Commitment 概念我在落地时用一张 stock_claims 表来承载承诺。订单创建时写一条占用承诺出库事件发生后再把承诺置为已履约。这样可用库存就等于资源当前余额加上预计入库承诺再减去预计出库承诺而不是简单看库存余额表。承诺层的存在解决了两个问题第一下单时就能判断能不能履约避免“先接单、后超卖”第二把未来的资源变化也纳入统计管理者能看到相对准确的可用资源视图。很多 ERP 系统所谓“可用量”算不准就是因为承诺没有事件化只停留在订单行的备注里。3. 核心实现表结构、领域服务与关键计算3.1 数据库表怎么设计考虑到这套模型要同时服务库存、财务、审计表结构尽量保持简单但约束要严格。以下是我在项目里使用的核心表字段做了精简CREATE TABLE agents ( id BIGINT PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, -- CUSTOMER / SUPPLIER / WAREHOUSE / OPERATOR name VARCHAR(128) NOT NULL ); CREATE TABLE resources ( id BIGINT PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, -- INVENTORY / CASH / RECEIVABLE / COST name VARCHAR(128) NOT NULL, uom VARCHAR(10) NOT NULL, current_qty DECIMAL(20, 4) NOT NULL DEFAULT 0, currency_code VARCHAR(8) ); CREATE TABLE economic_events ( id BIGINT PRIMARY KEY, unique_key VARCHAR(96) UNIQUE NOT NULL, event_code VARCHAR(32) NOT NULL, -- PURCHASE_IN / SALES_SHIP / CASH_RECEIPT resource_id BIGINT NOT NULL REFERENCES resources(id), quantity DECIMAL(20, 4) NOT NULL, direction SMALLINT NOT NULL CHECK (direction IN (-1, 1)), agent_id BIGINT NOT NULL REFERENCES agents(id), counterparty_id BIGINT REFERENCES agents(id), occurred_at TIMESTAMP NOT NULL, business_ref VARCHAR(64), created_by BIGINT REFERENCES agents(id) ); CREATE TABLE event_links ( id BIGINT PRIMARY KEY, left_event_id BIGINT NOT NULL REFERENCES economic_events(id), right_event_id BIGINT NOT NULL REFERENCES economic_events(id), link_type VARCHAR(20) NOT NULL -- DUALITY / REVERSE / FULFILLS ); CREATE TABLE stock_claims ( id BIGINT PRIMARY KEY, resource_id BIGINT NOT NULL REFERENCES resources(id), quantity DECIMAL(20, 4) NOT NULL, direction SMALLINT NOT NULL CHECK (direction IN (-1, 1)), status VARCHAR(12) NOT NULL, -- ACTIVE / FULFILLED / CANCELLED event_id BIGINT REFERENCES economic_events(id), claimed_by_ref VARCHAR(64) ); CREATE TABLE accounting_entries ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES economic_events(id), account_code VARCHAR(32) NOT NULL, debit DECIMAL(20, 4) NOT NULL DEFAULT 0, credit DECIMAL(20, 4) NOT NULL DEFAULT 0, entry_date DATE NOT NULL );资源表里的 current_qty 在严格建模里不算必须真正权威的数据是经济事件表。之所以保留 current_qty是因为高频查询场景需要快速拿余额但它只是缓存可以被事件表重算。另一个重要设计是 economic_events 的 unique_key 唯一约束这是幂等的最后一道防线后面讲问题排查时你会看到它有多关键。3.2 核心领域服务记录经济事件记录事件的领域服务是整个系统的咽喉。它要做四件事幂等检查、资源行锁、可用量校验、更新余额和生成凭证。下面用 Python 风格代码表示核心逻辑实际语言换成 Java、Go 都行。dataclass class EventCommand: event_code: str resource_id: int quantity: Decimal direction: int # 1: 资源增加, -1: 资源减少 agent_id: int counterparty_id: int | None business_ref: str unique_key: str claim_id: int | None None def record_event(cmd: EventCommand): with transaction.atomic(): resource Resource.objects.select_for_update().get(pkcmd.resource_id) # 1. 幂等检查 if EconomicEvent.objects.filter(unique_keycmd.unique_key).exists(): return # 2. 可用量校验 if cmd.direction -1: avail _available_qty_from_locked_resource(resource.id) if avail cmd.quantity: raise InsufficientStock(resource_idresource.id, quantitycmd.quantity) # 3. 写入事件并更新余额缓存 event EconomicEvent.objects.create( unique_keycmd.unique_key, event_codecmd.event_code, resource_idcmd.resource_id, quantitycmd.quantity, directioncmd.direction, agent_idcmd.agent_id, counterparty_idcmd.counterparty_id, occurred_attimezone.now(), business_refcmd.business_ref, created_bycmd.agent_id, ) resource.current_qty cmd.quantity resource.save(update_fields[current_qty, version]) # 4. 履约承诺 if cmd.claim_id: StockClaim.objects.filter(pkcmd.claim_id, statusACTIVE).update( statusFULFILLED, event_idevent.id ) # 5. 财务过账 _post_accounting(event)有几个点需要特别说明。第一幂等检查必须在事务里做并且配合数据库唯一约束只靠应用层的 check-then-insert 挡不住并发重复。第二select_for_update 锁定资源行是为了避免同一资源的两个事件并发扣减导致超卖。这会让同一资源的写操作串行化吞吐不是最高但正确性优先。第三数量全部用 Decimal绝对不能使用 float 做金额和数量运算。3.3 可用库存怎么算可用量计算需要把承诺层纳入。我的_available_qty_from_locked_resource实现思路是def _available_qty_from_locked_resource(resource_id: int) - Decimal: base_qty Resource.objects.get(pkresource_id).current_qty agg StockClaim.objects.filter( resource_idresource_id, statusACTIVE, ).aggregate( inboundSum(quantity, filterQ(direction1)), outboundSum(quantity, filterQ(direction-1)), ) return base_qty (agg[inbound] or 0) - (agg[outbound] or 0)这里的逻辑是current_qty 代表已发生事件累积的结果stock_claims 代表还没形成事件但已经承诺的预计流入和预计流出。所以可用量既不能只看余额也不能简单把承诺加进去而是“实际存量 预计入库 - 预计出库”。这样在下单时就能提前发现超卖而不是等到发货才发现库存不够。这里有个常见误区有些系统会把“锁定库存”单独做减项又搞一个“可用库存”字段。结果同一份数据在两个字段里反复同步很容易出 bug。我的做法是只把承诺事件化不额外维护可用库存字段所有派生值都现场算。虽然会多一点查询开销但换来的是口径统一值得。3.4 从经济事件自动生成财务凭证财务凭证生成是 REA 落地时最容易被卡住的地方。很多人会问事件表和结转凭证什么关系实际上每个经济事件可以按照事件类型映射成一组会计分录。我在项目里用一张配置表维护“事件类型到借贷科目”的映射事件码借方贷方备注PURCHASE_IN库存商品应付账款采购收货SALES_SHIP主营业务成本库存商品销售出库结转成本SALES_INVOICE应收账款主营业务收入收入确认CASH_RECEIPT银行存款应收账款收到货款PAY_OUT应付账款银行存款支付货款映射规则不是写死在条件分支里而是存到一张 accounting_rule 表事件类型、借方科目、贷方科目、金额取值字段都可以配置。这样财务调整科目时不用改代码改配置即可。凭证生成放在与事件同一个事务里避免“库存动了、凭证没动”的中间状态。需要特别指出不是所有事件都要生成凭证。比如仓库内部移库只是库存资源从 A 仓库变成 B 仓库资源总额没变就不需要生成凭证只有涉及外部主体或财务价值变化的事件才需要过账。这个规则可以在配置表里用 should_post 字段控制。4. 真实项目里的坑问题与排查实录4.1 幂等失效导致库存翻倍第一次联调时仓储系统因为网络超时把入库回执重发了三次结果库存从 100 变成 200。一开始在领域服务里确实写了 unique_key 检查但多个重试请求同时到达时两个事务都查不到已有记录就都执行了插入。这个问题的根因是应用层检查存在并发窗口。最终解决办法是在 economic_events.unique_key 上加了数据库唯一约束并将唯一键生成规则定为“业务单据类型 单据号 行号 动作类型”。从那以后任何重复请求都会在数据库约束层被挡下来。这个经验也可以推广到其他以幂等为核心要求的系统里业务幂等不能只靠代码保证数据库唯一约束必须兜底。4.2 并发扣库存导致死锁预售活动开始后大量订单集中转发货数据库频繁报 deadlock。排查下来是不同事务先更新了 stock_claims再更新 resources锁顺序不一致导致互相等待。修复方式很直接所有写库存相关资源的事务必须先锁资源行再去处理承诺行。也就是说把select_for_update放在事务最前面锁顺序全局统一。应用层还加了类似“按资源 ID 排序后再锁”的约定保证不会出现两个资源互相等待。这里我建议在代码评审时重点检查每个事务的锁顺序死锁一旦发生日志里只会看到“try restarting transaction”不会告诉你代码哪里错了提前定好规范比事后查死锁轻松得多。4.3 对账不平余额重算器是救命工具项目上线第三周财务反馈某商品金额对不上。我写了一个重算脚本对一个资源从 economic_events 表按时间排序累加所有 quantity再与 resources.current_qty 对比差额不为零就把造成差额的事件列表打印出来。依靠这个重算器很快就发现有一条手工 UPDATE resources 的脚本历史把余额直接改了但没有生成事件。后来定了一个纪律所有余额变更必须通过 record_event 走事件写入禁止任何绕过服务的直接更新。代码审查的时候也会专门查手写 SQL 修改核心表的情况。这件事让我体会到REA 模式的价值不只在建模阶段更在于它自带一个“可重放、可校验”的审计基线出现问题能快速定位到具体业务动作。4.4 测试时要盯住不变量这类系统比普通 CRUD 更适合用不变量测试。我在项目里定义了三条核心不变量资源当前余额等于该资源所有已确认事件数量之和同一 unique_key 最多只能出现一次事件对偶链上每一条记录的双方都必须存在且方向相反。测试时用随机序列生成一组事件跑完逻辑后立刻断言不变量任何一个不满足都能快速定位到具体事件。这个方法比写几百个手工用例更高效尤其是重构阶段。因为事件类型会不断增加手工用例永远追不上新需求不变量测试却能一直守住系统最核心的规则。如果你也在重构类似系统我建议把不变量先写成一等公民的测试类而不是散落的临时断言。4.5 容易被忽略的五个细节第一金额和数量字段必须统一用 DECIMAL涉及精度运算时特别注意小数位对齐。第二事件时间统一存 UTC业务日期、账期日期单独存字段不要混用。第三事件表只允许追加不允许物理删除要改正错误就新增一条反向事件并在 event_links 里关联原事件这样审计链路不会被截断。第四主体不只是客户和供应商操作员也应该建模成 Agent否则审计时说不清哪笔操作是谁发起的。第五别在资源表上不断加状态字段状态其实都是事件的派生结果加多了就又回到老路。5. 在这个骨架上继续扩展5.1 预算和费用的落地预算本质上也是一种资源。把“可用预算”建模成预算资源余额业务申请预算时写入承诺审批通过后生成事件预算余额自动减少。这样预算就和库存用了同一套机制不需要单独开发一套“预算冻结、报销、冲销”的状态机。审批流和承诺层天然配合审批中的申请单可以看作未生效的承诺审批通过后承诺转为事件逻辑非常顺。5.2 多币种和汇率处理如果业务涉及多币种资源表需要增加币种字段经济事件除了记录数量还要记录币种、原币金额和本币金额。汇率本身也可以建模成一种资源或者至少保留每次事件发生时的汇率快照避免后续重算凭证金额时出现汇率不一致。财务系统对汇率口径很敏感同一个外币金额用月初汇率和用实时汇率做账月末汇兑损益完全不一样所以汇率快照必须跟着事件走。5.3 给 BI 系统喂数据经济事件表本身就是天然的事实表资源、主体是维度表。报表要算销售额、销售成本、库存周转都可以直接基于事件表聚合不需要从多个业务模块同步数据。项目后期 BI 同事反馈建模成本低了很多。以前取数要理解每一套单据状态现在只需要告诉他们“哪个事件表示销售发货、哪个事件表示收款”他们就能自己写报表了。5.4 我最后想说的实操体会如果我重新做一个类似系统会坚持两件事可变状态只收口到“资源余额 承诺状态”两个地方其余全部视为派生视图每次业务动作都走事件入口哪怕只是改一个电话号码也写成“资料变更事件”。REA 不是银弹但它能强迫你面对“什么才是真实发生的事实”这个问题。把事实建模清楚后面加权限、加审批、加审计、加报表都只是往骨架上挂肉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑