首页 / 资讯中心 / 文章详情

REA业务建模实战:以资源-事件-代理重构进销存与资金流

REA业务建模实战:以资源-事件-代理重构进销存与资金流 ★ FEATURED ARTICLE
前阵子接手一个项目代号叫rea我第一反应是“一封没写完的邮件”直到打开建模文档才意识到这是 Resource-Event-Agent 的缩写也就是资源、事件、代理三要素构成的业务建模方法。这个三字母词在会计信息系统和业务数据建模圈子里不算新但对正在做进销存、财务平台、供应链数字化的人来说它提供的视角非常值得聊一聊。传统直觉里我们建模订单系统第一张表可能就是“订单表 订单明细 商品表”账务系统则是“凭证表 科目表”。REA 完全换了一个思路它不管凭证长什么样而是把每笔业务拆成“谁代理用什么资源发生了什么事件”再由这些事实去推导出账目、库存、应收应付等结果。简单说它是一套“事件驱动的业务事实层”。这篇文章我打算以手头那个代号为rea的进销存 资金流重构项目为例把三要素如何落到数据库、业务流程如何展开、以及对账和追溯为何变得容易一次讲清楚。适合正在做业务系统模型设计、会计系统升级或准备搭建审计数据仓库的读者参考。文中会贴一部分可运行的建表 SQL 和后端入账逻辑都是来自实际项目的简化版可以直接当脚手架用。1. REA 项目解码为什么业务建模要从“事件”出发1.1 传统“凭证 科目”模型为什么让我越用越别扭大多数传统业务系统尤其是财务相关模块底子都是“凭证 科目 金额”这套结构。业务人员在前台录入一张销售单后台系统自动生成几张凭证每张凭证挂上借方科目、贷方科目再把金额打散。这套结构在会计核算层面没有问题但一旦要回答“这个月某客户到底欠我们多少钱”或者“这批货是哪个供应商送的”这类业务问题时就非常吃力。原因在于凭证是会计人员加工后的结果不是原始业务事实。同样是“销售出库”这个动作可能被拆成收入确认、库存结转、税额计提等多条凭证。每条凭证都只保留了一个科目的视角丢失了订单号、销售员、客户、商品批次这些上下文。数据量一大月底对账就变成人肉 SQL 大作战左表关联右表sum 来 sum 去最后还经常差几毛钱。我在实际重构中遇到过一个更典型的例子系统里有两千多条金额完全一致但方向相反的凭证业务上确实是一笔真实回款但凭证之间根本没有外键关联。后来业务换了财务负责人要求按客户重新出应收账龄表开发同学只能把凭证表 join 订单表再 join 客户表逻辑里塞满了各种CASE WHEN和去重条件。这就是典型的数据模型把业务事实拆碎之后再想拼回去的成本。REA 模型解决的就是这个问题它要求系统把“业务事实”本身作为一等公民来存储而不是存储“会计核算后的记号”。凭证仍然需要但它是从事实推导出来的结果不再作为唯一的信源。1.2 资源-事件-代理三要素的通俗解释REA 的核心概念只有三个但理解清楚了会发现它几乎可以覆盖所有业务系统的底层语义。资源Resource只要有价值、可以被经济主体拥有或控制的东西都是资源。比如库存商品、现金、银行存款、固定资产甚至品牌使用权和服务能力。判断一个东西是不是资源的标准很简单它会不会出现在资产负债表或库存报表里或者它是否参与了价值的流入流出。事件Event让资源发生增减变化的经济活动。比如采购收货增加了商品资源销售发货减少了商品资源客户回款增加了现金资源给供应商付款减少了现金资源。事件是 REA 模型的灵魂它与传统 log 流水的最大区别是携带完整的业务上下文而且不可随意修改。代理Agent参与事件的经济主体分为内部代理和外部代理。内部代理是组织内部的参与者比如销售员、采购员、仓库管理员、财务人员外部代理是组织外部的对手方比如客户、供应商、物流商。一个完整的经济事件通常至少需要两个代理一个内部代理、一个外部代理。三者的关系用一句话概括“某个代理通过某个事件用某项资源和其他代理完成了经济交换”。这不是咬文嚼字而是业务系统本质上在不断记录资源的转移商品从我们仓库转移到客户手里现金从客户手里转移到我们银行账户里。只要资源转移的过程被完整记录账表就只是查询逻辑的事。1.3 项目代号 rea 的目标设定与技术选型给项目起名rea本身有点随性但这个代号承载了我对这次重构的预期它要成为一套“资源-事件-代理”驱动的业务建模规范附带一套能直接使用的数据库脚手架代码。和市面上大多数业务框架不同rea不打算成为 ORM也不打算接管业务逻辑它只解决一件事把业务事实的落库方式统一成事件流模型。技术选型上项目最终确定了“关系型数据库 事务性服务层”的组合。没有上复杂的事件溯源框架主要原因有三点一是当前团队对数据库事务的熟悉度远高于对消息中间件的维护能力二是业务对实时一致性要求很高不希望因为事件总线的延迟导致仓库存货不准三是审计需求要求账实一致关系型数据库加唯一约束能做到极强的可校验性。在建模粒度上我刻意做了收敛。没有把 REA 理论里的承诺、调度、细粒度控制权全部铺开而是先抓住“资源增减事件 代理参与 事件对偶”这条主线。理论模型讲求完整性工程落地讲究最短路径这两者需要在项目初期就取得平衡。2. REA 数据模型设计几张表存下完整的业务事实2.1 核心表结构与设计原则落地 REA 模型我并没有按严格的理论教程拆出十几个表和一堆抽象类型因为那样维护成本太高。实际使用中我把模型收敛成四张核心表经济事件表、事件资源明细表、事件代理关联表、事件对偶关联表。这四张表构成了业务事实层的基座。经济事件表存储每个事件的主体信息包括事件类型、发生时间、事件状态、来源单据编号、幂等键、经办人等。事件资源明细表描述该事件对哪些资源产生了什么方向的影响用正负数表示流入流出。事件代理关联表描述这个事件涉及了哪些外部代理和内部代理。事件对偶关联表则表达经济交换关系把一收一付两个事件绑成一对。CREATE TABLE economic_events ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, status VARCHAR(16) NOT NULL DEFAULT committed, ref_no VARCHAR(64), idempotent_key VARCHAR(128) UNIQUE, description TEXT, created_by VARCHAR(64), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE event_resource_lines ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES economic_events(id), resource_id BIGINT NOT NULL REFERENCES resources(id), delta_quantity NUMERIC(18,6) NOT NULL, unit_cost NUMERIC(18,6), memo TEXT ); CREATE TABLE event_agents ( event_id BIGINT NOT NULL REFERENCES economic_events(id), agent_id BIGINT NOT NULL REFERENCES agents(id), role VARCHAR(16) NOT NULL, -- internal / external PRIMARY KEY (event_id, agent_id, role) ); CREATE TABLE event_duality_links ( id BIGSERIAL PRIMARY KEY, inward_event_id BIGINT NOT NULL REFERENCES economic_events(id), outward_event_id BIGINT NOT NULL REFERENCES economic_events(id), linked_amount NUMERIC(18,6) NOT NULL, linked_at TIMESTAMPTZ NOT NULL DEFAULT now() );设计上有两个原则值得展开。第一个原则是不混层事件表只记录事实和业务含义不存借贷标志不存科目代码。第二个原则是尽量用基础表加关联表而不是把关键信息塞进 JSON 字段里。JSON 字段虽然扩展方便但后续做累计求和、数据校验、报表关联时性能和可读性都会变差。在早期版本里我还专门建过一张事件类型字典表用来维护每种事件类型对应的资源影响方向。比如sale会对商品资源产生负向影响对现金资源或应收资源产生正向影响。这套配置看似多了一层实际让后端的入账逻辑变得非常简单新增一种业务动作不再需要修改代码只需要配置事件类型和资源增减规则。2.2 事件不可变与幂等审计追溯的生命线REA 模型对事件有一个硬性要求已经提交的事件不允许修改也不允许物理删除。出错了怎么办用一个相反的新事件把它冲销掉。这个原则在很多开发者看来很麻烦但它恰恰是这套模型的护城河。试想一个场景业务员误把一笔销售发货的数量从 10 改成 12。如果事件表允许直接改那么库存表、应收表、毛利表都会随之变动但系统里没有任何痕迹证明这笔数据被改过。审计时发现账实不符根本无法判断是原始业务就是 12还是系统被人为篡改过。在rea项目里我强制所有事件都走只增模式同时为每个事件生成全局唯一的幂等键。幂等键的作用是防止接口重试导致同一笔业务被登记两次。幂等键的设计要非常小心。最稳定的做法是使用业务单据号与应用层业务动作的组合比如PO-2025-001:RECEIPT。这样一个采购单只允许产生一次收货事件重复调用会被唯一约束拦住。如果是纯前端生成的 UUID很难防止用户在极端情况下提交两笔相同内容但不同 ID 的业务所以最好的幂等键是业务自然键而不是随机键。事件不可变还有一个连带好处所有汇总结果都可以从基础事件流重建。就算某天的库存月结算错了也只需要修正当时那段计算逻辑然后从事件流重新跑一遍即可不需要手工去改余额表。这种可重算性在审计场景里价值极高。2.3 事件对偶Duality复式记账的等价实现很多第一次接触 REA 的人会问它怎么保证借贷平衡传统复式记账要求有借必有贷借贷必相等REA 不记借贷但它用事件对偶实现了同样的平衡语义。经济交换天然是“一进一出”的。销售发货把商品资源转移给客户这是一个资源流出事件客户回款把货币资源转移给我们这是一个资源流入事件。这两件事在业务上是同一笔交易的左右两半在 REA 模型里通过event_duality_links表绑定。采购收货和支付货款同理生产领料和完工入库同理。这套对偶关系使得“平账”变成结构性的而不是计算性的。传统凭证模型在入账后还要跑试算平衡去查有没有漏记REA 模型在建对偶关系时就已经保证了每笔交换都有流出方和流入方。我在系统实现中写了一个校验服务在提交事件时检查如果事件类型被标记为需要建立对偶关系并且对偶金额与实际资源变动金额不一致则整笔事务回滚并抛出明确的业务异常。对偶关系还有一个额外价值它天然支持“带上下文追溯”。通过对偶表可以从收款事件直接跳到对应的销售发货事件再到订单、商品、客户、销售员。这种链路追踪能力在做应收账龄分析、客户信用风险评估、销售毛利拆分时几乎开箱即用。3. 从 0 到 1 落地一个 REA 业务引擎3.1 一次真实的业务建模过程我在某小型经销商进销存项目里完整验证过rea方案。业务规模不算大但类型丰富有采购入库、采购付款、销售发货、客户回款、库存调拨、盘盈盘亏。建模第一步不是建表而是把业务环节逐条梳理成“资源 - 事件 - 代理”的描述。以一条最简单的“采购收货”为例采购员从供应商处收到一批商品仓库管理员验收入库。在这个业务事实里商品库存是资源采购收货是事件供应商是外部代理采购员和仓管员是内部代理。再看“给供应商付款”银行存款是资源付款是事件供应商是外部代理财务人员是内部代理。同一个外部代理关联到两笔不同的事件这两笔事件又通过采购订单形成对偶关系。用表格把核心环节整理出来整个建模过程会清晰很多业务步骤资源流向外部代理内部代理事件类型编码采购收货库存商品增加供应商仓管员purchase_receipt支付供应商货款银行存款减少供应商财务pay_supplier销售发货库存商品减少客户销售员sale_shipment客户回款银行存款增加客户财务receive_cash建模步骤本身很朴素但做完这一步整个系统的骨架已经浮出水面。后续所有代码都在围绕这些事件类型展开不会出现“看到一个库存流水但不知道它来自哪张单据”的问题。3.2 初始化资源与首期建账新老系统切换时最麻烦的是期初数据。传统做法是建一张期初余额表把库存数量、应收余额、应付余额手工录进去。在rea模型里我仍然保留了期初余额表但把它当成一种“快照”而不是业务事件。这样设计的好处是日常余额计算以事件流为准期初余额只作为起算点。为了保证起点可追溯我在经济事件表里支持了一种特殊事件类型opening_balance它没有代理参与只对资源做正向赋值。初始化时插入一批opening_balance事件再配合当前库存快照表就能随时校验事件流推导出的余额与实盘是否一致。初始化资源的 SQL 尽可能做成幂等脚本。比如在一个事务里先清空所有历史汇总表再重新插入期初事件最后重建物化视图。这个流程在项目上线时跑一次后续每年年初如果切换财务年度也可以复用。BEGIN; INSERT INTO resources (id, name, resource_type) VALUES (101, 商品A, inventory), (102, 商品B, inventory), (103, 银行存款, cash); INSERT INTO economic_events (event_type, occurred_at, status, ref_no, idempotent_key, description) VALUES (opening_balance, 2024-01-01 00:00:0008, committed, OPEN-2024, OPEN-2024-INIT, 期初建账); INSERT INTO event_resource_lines (event_id, resource_id, delta_quantity, unit_cost) VALUES (1, 101, 500, 80.00), (1, 102, 300, 120.00), (1, 103, 200000, 1.00); COMMIT;这个初始化过程看似简单却有一个必须注意的坑期初事件不要与真实交易事件混在一起筛选。很多报表逻辑会按事件类型过滤业务如果不小心在时间范围上包含了期初事件金额会重复计算。我的做法是给期初事件打上独立标记所有常规查询默认排除opening_balance只有余额计算才纳入它。3.3 登记采购收货和资金支出业务引擎的核心是一个统一的入账服务。任意业务动作进来服务负责在同一个数据库事务里完成三件事创建事件主记录、写入资源增减明细、写入代理关联。如果该事件需要建立对偶关系则同时写入对偶表。下面用简化的 Python 风格伪代码展示核心逻辑实际项目里我把它封装成了一个服务类def post_event(event_type, occurred_at, ref_no, resource_lines, agents, paired_event_typeNone): with transaction.atomic(): # 幂等检查业务单据号 事件类型 if EconomicEvent.objects.filter(event_typeevent_type, ref_noref_no).exists(): raise BusinessException(重复事件单据已登记) event EconomicEvent.objects.create( event_typeevent_type, occurred_atoccurred_at, ref_noref_no, idempotent_keyf{ref_no}:{event_type}, statuscommitted, ) for line in resource_lines: EventResourceLine.objects.create( eventevent, resource_idline[resource_id], delta_quantityline[delta_quantity], unit_costline.get(unit_cost), ) for agent in agents: EventAgent.objects.create( eventevent, agent_idagent[agent_id], roleagent[role], ) # 对偶校验例如“收货”之后必须能找到对应“付款” if paired_event_type: validate_duality(event, paired_event_type) return event以“采购收货并付款”为例业务在这个系统里的完整动作被拆成两个独立事件先登记purchase_receipt再登记pay_supplier。我刻意不把两件事压到同一事务里提交理由很直白很多真实业务中货先到、款后付两件事之间隔着账期。如果强行要求同时发生反而违背业务事实。但两个事件之间的对偶关系必须可查询。我在对偶表里记录了付款事件对应的收货事件将来无论从哪个方向出发都能找出完整的交易链路。实际运行中对账人员问得最多的问题就是“这个供应商我们付款付到哪一笔货了”在传统模型里这个问题异常难答在rea模型里直接关联对偶表即可。3.4 计算库存余额、应收应付和毛利模型建成后最爽的部分是查询逻辑变得统一了。库存余额等于所有增加性事件减去所有减少性事件的代数和应收余额等于所有销售应收事件减去所有收款抵减事件的结果应付余额同理。只要事件登记准确汇总查询就是标准的分组求和。-- 计算当前商品库存余额 SELECT r.id AS resource_id, r.name AS resource_name, COALESCE(SUM(erl.delta_quantity), 0) AS current_balance FROM resources r LEFT JOIN event_resource_lines erl ON erl.resource_id r.id LEFT JOIN economic_events e ON e.id erl.event_id WHERE e.status committed AND e.event_type opening_balance GROUP BY r.id, r.name;毛利计算则依赖对偶关系。在传统系统里毛利要把收入类凭证和成本类凭证按订单号 join 起来在rea模型里我先取收款事件或销售事件再通过对偶表找到对应的出库事件出库事件上的加权成本就是销售成本销售事件上的收入金额减去成本就是毛利。由于所有关联关系都是结构化存储计算逻辑不会因为业务复杂化而膨胀。SELECT s.id AS sale_event_id, s.ref_no, SUM(ink.delta_quantity * ink.unit_cost) AS sales_cost, sales_amount.amount AS sales_amount, sales_amount.amount - SUM(ink.delta_quantity * ink.unit_cost) AS gross_profit FROM economic_events s JOIN event_duality_links d ON d.inward_event_id s.id JOIN event_resource_lines ink ON ink.event_id d.outward_event_id JOIN ( SELECT event_id, SUM(delta_quantity * unit_cost) AS amount FROM event_resource_lines WHERE event_id ANY(%s) GROUP BY event_id ) sales_amount ON sales_amount.event_id s.id WHERE s.event_type sale_shipment GROUP BY s.id, s.ref_no, sales_amount.amount;这段 SQL 是从项目里精简出来的真实代码会更绕一些因为要处理多币种和税率但核心思路不变一切结果皆可推导一切推导皆可复核。月底结账时不再需要“平账”环节只需要校验事件层面与科目层面的汇总是否一致。4. REA 实战避坑我在对账系统里踩过的四个坑4.1 坑一把事件表当普通流水表随意改金额REA 模型第一个落地时会遇到的挑战就是习惯性想要 UPDATE 已登记的事件。业务人员发现某个销售单金额录错了第一反应是“把这个事件的金额改掉”。如果放任这种行为事件层就变成了又一个流水表之前追求的可追溯性全部归零。正确的处理方式是把错误流转成新的事件。比如销售单金额录多了一百块不是更新原事件而是登记一笔“销售金额调整”事件金额为负数并关联到原事件编号。原事件在账面上保持完整新事件记录了调整的理由和操作人。这样审计时看到的是连续的业务痕迹而不是一个前后对不上的金额。实际操作中这需要两个配套设计一是前台界面上不允许对已登记事件提供“编辑”按钮只提供“红冲”或者“调整”入口二是事件类型要预留adjustment、reverse、writeoff等通用类型否则遇到异常业务时没有可用的落账通道。4.2 坑二漏建对偶关系报表永远平不了对偶关系是整个 REA 模型中最容易被省略的部分。很多团队建了三要素表之后事件也记了资源也变了但对偶表始终是空的。看起来一切正常直到要生成往来对账单或毛利报表时才发现无法把收款事件关联到对应的销售事件。漏建对偶关系往往源于一个认知偏差认为事件本身已经包含了全部信息对偶表是冗余设计。但问题是同一笔销售可能有多次部分回款一次回款可能同时覆盖多笔销售如果不显式记录份额对应关系系统根本无法自动确定某笔回款是冲哪笔销售的。我在项目里用对偶表的同时还加了一个字段记录“本次核销金额”保证多对多核销也能精确到分。建议在事件入账事务里强制校验“需要建立对偶关系的事件类型”是否都有对偶记录。不要靠业务人员自觉要靠系统约束。代码里哪怕前面报错后面调整也比数据写脏了再去补救成本低得多。4.3 坑三余额查询全表扫描数据一多就卡死理论上REA 的事件表是标准的审计事实表它会不断增长。采购、销售、付款、收款每天都在产生事件几个月后可能就是百万级甚至千万级。如果每次查库存余额都直接SUM全部历史事件数据库会越来越慢最终连简单页面都会超时。我踩过这个坑后做的第一件事是增加“余额日结表”。每天凌晨或者每次事件提交后汇总各资源的当日余额变动快照。查询当前余额时不再全表扫描事件而是读最近一天快照加上当天的增量事件。这个方案本质上是“事件事实表 余额快照表”的组合既保留了追溯能力又避免了长周期累计扫描。另一个优化是事件表按月份分区。实际对账时大多数查询都带有明确的时间范围分区表能有效缩小扫描范围。分区字段我建议用事件发生时间occurred_at而不是系统时间created_at因为业务上查账永远是按业务时间维度来查的。4.4 坑四期初余额处理不当账实永远差一截期初余额是切换系统时最容易出错的地方。很多设计直接把期初余额写成一行特殊记录放在余额表里但事件流看不到它。结果就是业务事件算出来的余额与财务给出的期初余额之间有一道“看不见的缝隙”对不平。我在rea项目中用的策略是期初余额以opening_balance事件形式进入事件流同时保留告警校验。每次做月末结账时系统会把事件流推导出的余额与独立存储的期初余额快照做对比差值大于设定阈值就会自动告警。这个机制在第一季度运行中多次抓出了初始化数据遗漏和重复导入的问题。如果你正在做类似的迁移建议给所有期初事件加一个统一的特殊标识并禁止普通业务统计包含它们。这个标识最好独立于事件类型字段避免查询时为了过滤opening_balance把所有 SQL 都加一段条件时间长了非常容易漏。4.5 另外一个容易被忽略的操作细节事件类型与科目映射REA 模型虽然不把凭证作为核心但最终还是要生成凭证给财务系统使用。项目中期我补了一张“事件类型到会计科目”的映射表每种事件类型对应它可以产生的科目及增减方向。比如sale_shipment事件有库存商品减少对应的是主营业务成本增加客户回款对应的是银行存款增加、应收账款减少。这张映射表的价值在于它让“事件层”和“凭证层”彻底解耦。事件层负责沉淀业务事实凭证层只是事件的一种输出视图。财务科目调整时只要改映射表历史事件流不需要重写。我在项目里把这个逻辑做成了一个后台管理页面财务人员可以自行维护对应关系不再依赖开发改代码。还有一个适配多业务线的细节不同业务线对同一事件类型的科目要求可能不同。早期我只按事件类型映射后来发现需要加上“业务线”维度。比如同一个sale_shipment事件在 A 业务线计入主营业务收入在 B 业务线计入其他业务收入。映射关系因此变成一个组合键事件类型 业务线 资源类型。这个扩展在建模时要留好余量否则后面改造会非常痛。5. 从rea项目里沉淀出来的几点个人心得如果重新做一遍这个项目我会在第一天就坚持“事件先于凭证、资源先于科目、事实先于汇总”这三条原则。REA 模型最值钱的地方不是几张表的名字而是它迫使团队重新思考什么才是系统里真正不可变、可审计、可重建的核心数据。对于还在用“订单表 明细表 凭证表”硬扛复杂业务的项目我建议不要急着推倒重来而是先把事件流作为事实层加到现有表和凭证表之间。把销售单、采购单、收付款单逐步改造成事件凭证改成事件的输出结果。改造成本会比预期高因为要处理大量历史数据的补录和映射。我个人在实际操作中的体会是事件驱动的建模方式最适合从“对账痛苦”最明显的领域切入比如应收应付、库存追溯、毛利分析。先在一条主链路上跑通让业务人员直观感受到“每一笔账都能找到原单据”的价值后续再推广就顺利很多。越是追求一步到位的全量重构越容易中途放弃。rea项目目前的版本已经能支撑分钟级的库存与资金流水同步月底对账从原来的三天缩短到一个小时。后续我还打算把事件流逐步推给客户对账单自动生成和应收账龄预警本质上都是同一套事实层上的新查询场景不需要改动底层的入账逻辑。如果你手头也有一个三字母代号的项目或者正在设计一套进销存和资金流模型不妨先把“资源 - 事件 - 代理”这套思路套在你的核心业务链路上试一次。从一条“销售 - 回款”链路开始用不了多少代码就能体会到事件驱动建模带来的清晰感。踩过几个坑之后你会发现这套思路比想象中好用也比想象中更需要用纪律去维护它的纯净性。
阅读完成 · 觉得有帮助?
咨询建站