REA模型实战:用资源-事件-代理统一业务与财务语义
第一次听到 rea 这个缩写很多人会往某个框架或动画库上去猜。它不是前端工具也不是某个实现语言的缩写rea 在业务建模领域通常指 Resource-Events-Agents中文习惯叫“资源—事件—代理”模型。我在做某套采购库存系统的重构时真正把它落到代码里当时最头疼的不是接口性能而是业务看的“订单已签收”和财务要的“发票未到”永远对不上。两边用同一套系统却像在说两种语言。最后靠 REA 模型把底层语义统一了这种撕裂感才真正消失。这篇文章不会停在概念层。我会把 REA 拆成人话用一套采购到付款的完整流程演示表结构和查询怎么写也会把我在实际项目中踩过的坑全部摆出来。如果你是后端开发、业务架构师或者在为账务系统、订单中心、库存系统做模型设计这篇文章值得一口气看完。1. REA 模型到底在解决什么问题1.1 传统科目体系为什么会越来越拧巴很多账务系统的数据模型是从“会计科目”出发的。每一笔业务都要翻译成借什么、贷什么然后落到凭证表里。这样做的好处是符合财务习惯但坏处也很明显当业务复杂起来每一类新业务都要找科目去“装”找不到就硬造一个。我见过一个真实的库存系统为了区分“正常出库”“样品出库”“售后出库”在库存流水表里加了一堆type字段还在科目表里建了一排含义模糊的明细科目。到月底对账时业务说自己发了 3000 件货财务说系统里只记了 2800 件最后查出来是“样品出库”走了另一套单据两边的统计口径压根不一致。这类问题的根源不是代码写错了而是数据模型里根本没有描述“业务事实”的结构。你记的是“科目”不是“发生了什么”。REA 模型恰恰换了一个角度先把业务事实完整记录下来至于怎么记账那是后面推导出来的事情。1.2 REA 的三要素用三句话说明白REA 模型由三组核心概念组成资源Resource对企业有价值、可以被交易或消耗的东西。现金、库存商品、应收账款、服务工时都是资源。事件Event让资源增加或减少的动作。采购入库让库存增加销售出库让库存减少收款让现金增加。代理Agent参与事件的人或组织。客户、供应商、员工、部门都属于代理。一个典型的销售业务用 REA 来拆就是这样资源库存商品、现金事件销售出库、收款代理客户、销售员你看这里没有“会计科目”的位置。科目是“视图”REA 才是“底稿”。1.3 一个销售动作的 REA 拆解假设一笔销售卖给客户 A 一批货仓库发货之后客户打了货款。用 REA 表达整个过程是两个经济事件销售出库事件库存商品减少客户 A 获得商品。收款事件现金增加客户 A 支付货款。这两个事件之间存在一个交换关系Duality出库事件和收款事件在业务上是一对它们共同构成一笔完整的经济交换。这个关系非常重要后面做“应收应付”查询时全靠它。从中能看到REA 模型的每一个节点都在回答业务问题什么资源参与了什么事件发生了谁对这个事件负责当数据表里能回答这三个问题业务和财务的沟通就有了同一个基准。2. 落地前必须想清楚资源、事件、代理怎么定2.1 资源不是科目更不是余额初学者最容易犯的错是把“资源”直接理解成“会计科目”。这两个东西有本质区别。会计科目是分类维度它告诉你这笔钱归在哪个报表项目里资源是实实在在参与业务流转的对象。举例来说“库存商品”是资源“主营业务收入”就不是资源。收入是业务事件产生的结果不是流转的对象。我在设计时有一条判断标准如果这个东西不能“被某个事件增加或减少”它就不是资源至少不是 REA 语境下的资源。还有一个容易争议的点积分、优惠券算不算资源我的建议是如果积分只是营销工具、不能兑换实际商品就别把它建模成资源一旦它能兑换商品、能结转、能过期那它就是资源需要独立的资源表和事件表来记录发放、使用、过期。否则积分账永远对不平。2.2 事件要按动词梳理粒度决定审计能力事件是整个 REA 模型里最核心的部分。找事件有一个朴素的办法把业务流程里所有“动词”找出来。下单发货收货退货付款收款调拨盘盈这些动词对应的就是候选事件。但这里有个粒度问题。是“销售订单”算一个事件还是“销售出库”算一个事件我的经验是以资源实际发生增减的动作为准。下订单这件事并没有让库存或现金发生变化它只是对未来业务的一个承诺。如果把“订单”算作事件后续查询“库存结余”时还得额外排除未发货的订单逻辑会很难受。所以我会把“承诺”和“事件”分开建模。订单放在承诺表出库/收款等真正导致资源变动的动作放在事件表。这样审计时能清楚地看到这个承诺是否被履行了资源是什么时候真实变化的。2.3 代理不等于自然人角色模型必须单独建“代理”这个词听起来高大上其实就是参与事件的“人”或“组织”。但是在真实系统里同一个自然人可以有多重身份。张三可能是采购员也可能是审批人一个客户既是买方也可能在某些场景下是退货方。如果你用一张user表去关联所有事件后续做权限、审计、绩效考核都会晕。我建议把“人/组织”和“角色/身份”分开CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- customer / supplier / employee / department agent_name VARCHAR(128) NOT NULL ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(32) NOT NULL -- buyer / seller / approver / carrier );agent表存的是“谁”event_agent表存的是“以什么角色参与了什么事件”。这个设计有几个直接好处同一个客户既是买方又负责售后可以对应多条不同角色的关联记录。做数据权限时直接按role过滤不用改业务代码。审计追溯能看到“谁在什么角色下做了这笔操作”而不是只看到一个用户 ID。3. 从模型到代码一张采购链路的 REA 表结构3.1 最小可用的核心表设计理论讲再多不如直接看落地的表结构。下面这套设计我在多个项目里验证过可以说是 REA 落地的最小可用版本。CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- PRODUCT / CASH / RECEIVABLE unit VARCHAR(16) ); CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_code VARCHAR(64) NOT NULL UNIQUE, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL -- CUSTOMER / SUPPLIER / EMPLOYEE / DEPARTMENT ); CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- RECEIVE / ISSUE / PAYMENT / RECEIPT occurred_at TIMESTAMP NOT NULL, note VARCHAR(255) ); CREATE TABLE event_resource ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction SMALLINT NOT NULL CHECK (direction IN (1, -1)), quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,2) ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(32) NOT NULL ); CREATE TABLE event_link ( id BIGINT PRIMARY KEY, from_event_id BIGINT NOT NULL, to_event_id BIGINT NOT NULL, link_type VARCHAR(32) NOT NULL -- PAY_FOR / SETTLE / RETURN_FOR );这套设计看起来表比常规方案多但每一张都有不可替代的位置。event_resource里的direction字段是关键。用1表示资源增加用-1表示资源减少。为什么要这样设计因为SUM(direction * quantity)可以直接算出资源结余而且一条 SQL 就能覆盖所有事件类型不需要在查询里写一堆CASE WHEN来判断“这个事件是入库还是出库”。event_link表则记录了事件之间的业务因果。比如“收货”事件和“付款”事件通过link_type PAY_FOR关联起来这样应付账款的查询逻辑就从“按科目名称搜”变成了“按事件关系算”口径清晰得多。3.2 采购到付款的四步事件流我以一个“从供应商采购一批货”的场景完整走一遍事件流。第一步录入采购订单。这一步我在 REA 里不写成事件而是写成承诺Commitment。可以用purchase_order单独存储也可以在事件表里增加commitment_type字段区分。实际项目里我更倾向于单独建订单表因为它包含行项目、价格、交期等信息塞进事件表会让表结构变臃肿。第二步供应商发货仓库收货。收货是一个标准的经济事件。它让“库存商品”这个资源增加属于RECEIVE类型。在event_resource表里记录direction 1quantity 实际收货数量unit_price 采购单价resource_id 对应商品资源同时通过event_agent关联供应商角色为seller。第三步财务付款。付款事件让“银行存款”资源减少在event_resource表里记录direction -1quantity 付款金额resource_id 银行存款资源同时关联供应商角色还是seller。第四步建立事件关联。这是 REA 模型最有价值的一步。在event_link表里把“收货事件”和“付款事件”关联起来INSERT INTO event_link (from_event_id, to_event_id, link_type) VALUES (1001, 1002, PAY_FOR);这条记录的意思是付款事件是为了结算收货事件对应的应付义务。有了这张关系表后续做账龄分析、做供应商对账单都是基于这个链路来算不会出现“业务说付了财务说没收到对应货”的情况。3.3 从事件流向报表库存结余和应付账款怎么查REA 模型最让我喜欢的一点是报表不需要另建一堆汇总表直接从事件表就能推。查询当前库存结余SELECT r.resource_code, r.resource_name, COALESCE(SUM(er.direction * er.quantity), 0) AS balance FROM resource r LEFT JOIN event_resource er ON er.resource_id r.id GROUP BY r.resource_code, r.resource_name;注意这里用了LEFT JOIN保证没有发生任何事件的资源也会出现在结果里。SUM(direction * quantity)是所有入库事件和出库事件叠加后的净额天然就是库存数量。查询某个供应商的应付余额可以这样拆SELECT a.agent_name, COALESCE(SUM( CASE WHEN e.event_type RECEIVE THEN er.quantity * er.unit_price ELSE 0 END ), 0) AS received_amount, COALESCE(SUM( CASE WHEN e.event_type PAYMENT THEN er.direction * er.unit_price * er.quantity * -1 ELSE 0 END ), 0) AS paid_amount FROM event_agent ea JOIN agent a ON a.id ea.agent_id JOIN event e ON e.id ea.event_id JOIN event_resource er ON er.event_id e.id WHERE ea.role seller AND a.agent_code SUP001 GROUP BY a.agent_name;这个查询里received_amount是供应商发货后在系统里形成的应付义务paid_amount是已经付掉的部分两者的差值就是当前还欠供应商多少钱。当然现实项目里会有预付款、红冲、折扣等情况逻辑会更复杂。但核心思路始终一样余额和账款不是被“存”出来的而是被“算”出来的。只要底层事件数据完整随时可以换一套口径重新计算这正是 REA 模型的灵活之处。4. 老系统改造时REA 和复式记账如何互补4.1 别拿 REA 替换总账让它去生成凭证有一个误区必须说清楚REA 模型不是为了干掉复式记账它和借贷记账法是不同层次的东西。复式记账解决的是“怎么记录价值增减”REA 解决的是“业务事实是什么结构”。在落地的时候最优方案往往是用 REA 作为底层事件引擎再从事件数据推导生成会计凭证进入总账系统。我见过一个团队试图把 GL总账直接改成 REA结果发现财务想要的资产负债表、损益表都需要大量自定义口径硬套 REA 反而把简单问题复杂化了。正确做法是业务侧订单、库存、付款等操作全部记录为 REA 事件。转换层写一个事件到凭证的转换服务把 REA 事件映射成借贷分录。财务侧总账系统继续保留凭证从事件层自动生成。这样做的好处是业务侧的建模不受科目限制财务侧又能复用成熟的账务体系两边各取所长。4.2 事件到科目的映射关键在方向和代理事件到凭证的转换规则是整个改造中最有技术含量的部分。我提供一个很实用的思路按“资源类型”映射科目按“事件方向”决定借贷。def event_to_vouchers(event): entries [] for er in event.resource_lines: account mapping_by_resource(er.resource_id) entries.append({ account: account, amount: er.quantity * er.unit_price, direction: debit if er.direction 0 else credit }) # 根据 event_agent.role 补充往来科目 for ea in event.agent_lines: if ea.role seller: entries.append({account: 应付账款, direction: credit}) return entries这段伪代码的逻辑很清晰资源增减的方向决定了是借还是贷代理的角色决定了往来科目记到客户还是供应商。只要映射规则稳定凭证生成就是完全自动化的不需要人工判断。我在实际项目里还会为每条映射规则加一个“生效时间”和“业务类型”参数。因为会计科目不是一成不变的同一个资源在不同结算方式下可能映射到不同科目。把映射做成可配置的规则表比把逻辑硬编码在代码里可维护得多。4.3 哪些业务适合用 REA哪些硬套会很难受不是所有系统都适合 REA。我列一个简单的对照表方便你判断手里项目的情况。场景是否推荐 REA原因订单-库存-结算一体化系统推荐REA 的事件模型天然匹配业务流转多组织、多账套的集团系统推荐代理机制能清晰区分不同主体纯财务报表系统不推荐事件建模绕远路直接科目表更好用固定流程的报销系统谨慎简单场景用 REA 反而增加表数量和维护成本需要强审计追踪的业务推荐事件只追加不修改天然满足审计需求判断标准很简单如果你的系统里业务状态经常变化且不同角色对同一笔业务有不同的统计维度REA 能帮你省很多力如果你只是想把现有记账流程搬到线上REA 并不会带来实质收益。5. 常见踩坑与排查手册5.1 把状态字段当事件导致审计全乱我在项目里看到最多的问题是把“订单状态”当成了事实记录。比如订单表里有个字段status从“待发货”改成“已发货”就以为做了出库。这种方式对查询来说很友好但对审计来说是灾难。事件是不可变的。一个真实事件发生时你应该新插入一条事件记录而不是修改已有的状态字段。如果你发现某条库存流水“凭空减少”但事件表里找不到对应的出库事件十有八九就是代码里直接改了状态跳过了事件记录。5.2 重复投递和扣减库存幂等问题必须前置处理消息队列在 REA 架构里很常见仓储服务发一个“出库完成”事件库存服务消费后更新库存。但消息可能重复投递消费端如果没做幂等一次出库就会被计成两次。解决方式很简单event_resource表给event_id加唯一约束消费时先查一下事件是否已存在。如果事件已经处理过直接忽略。这个约束必须在建表时就加业务上线后再补会很痛苦因为历史脏数据会让你难以判断到底哪条记录是真的。5.3 常见问题速查表现象可能原因排查方向库存余额越算越多重复消费消息没有幂等约束检查event_id唯一索引应付账款对不上收货事件和付款事件没有建立event_link关系检查事件关联表数据报表数据不准事件粒度不一致比如把“订单”和“出库”混为一个事件统一事件定义订单走承诺模型审计追溯不到人事件记录没有关联代理角色检查event_agent的role字段红冲后余额错误直接删除了原事件应该新增一个取反向数量的冲销事件这里着重说下红线事件只能追加不能修改更不能删除。发现错误时正确做法是新增一个冲销事件把原事件的效果抵消掉。这样历史轨迹完整审计随时能还原当时发生了什么。5.4 落地 REA 的三个心法最后分享三条我在实践里沉淀下来的经验。第一先别追求完美的 REA 理论模型。把“资源、事件、代理”三张核心表建出来跑通一个主流程再迭代。一上来就建了二十多张表往往会死在过度设计上。第二事件表和明细表之间一定要有清晰的“来源”关系。每一行event_resource都必须能追溯到原始业务单据否则系统在关键时刻就是一笔糊涂账。第三设计时多站在查询的角度想。REA 模型擅长回答“某个资源的当前余量是多少”“某个代理的累计交易是多少”但如果你的核心查询是“本月每个产品的毛利”那就需要预先建立聚合表不能把所有统计都压在事件表上。我在实际操作中最深刻的体会是REA 不是一种数据库范式而是一种描述业务的语言。它能帮你把混乱的状态机、科目表、流水表重新收敛到“资源怎么变、谁做的、什么时候发生的”这几个原始问题上。只要事件数据是干净、完整的后续业务怎么变报表怎么追你都有底气。