资讯动态

REA模型实战:用资源-事件-主体重建库存采购与财务对账系统

发布时间:2026/10/21 22:22:45 来源:尧图企业网站定制
最近在重构一套库存采购系统的业务底层项目代号就叫rea。这个代号不是某个开发框架的缩写而是取自一种经典业务建模思想Resource-Event-Agent资源-事件-主体。做这套重构之前我也像大多数团队一样围绕“采购单”“销售单”“库存流水”这些单据表堆功能。直到有一次财务审计要求我们把一年前的订单状态、库存余额、往来账款全部串起来解释我才发现传统“结果导向”的表结构根本顶不住。这篇文章就把我这次实践中的建模思路、表结构、事件写入、状态推导、凭证生成和踩坑经验完整写出来希望能给正在做业务系统底层设计、或者想理清业务层与财务层关系的开发者一些直接可用的参考。1. 为什么我会用 REA 模型重做业务底层1.1 传统“单据流水”设计卡在哪了很多业务系统一开始都很简单建一张销售单表字段里带客户、商品、数量、金额建一张库存表每次出库入库直接增减数量再做一张资金流水表记录收付款。这样设计开发速度很快界面直接绑定单表 CRUD看起来没有任何问题。但系统运行半年后业务开始复杂了。销售发生退货库存要冲回财务那边要冲销但如果只记录“库存减少了 5 件”这一条结果别人根本不知道这 5 件到底是从哪一笔销售退回来的。又比如采购单里面写了“预计到货日期”但货可能分批到、付款可能跨月如果按单据状态去管理数据会被反复覆盖状态字段一天改三次最后没人说得清真实业务过程。这类问题的本质是传统设计把业务过程折叠成了结果。单据、金额、数量都是某一时刻业务过程的结果结果一旦被覆盖或者被更新原始事实就再也找不回来了。我在项目里遇到过一次最典型的场景为了修正一条错误库存记录直接在库存表上做了一个“调整单”把数量改回去。表面看数据对了但之后审计问“这批货到底哪一天进来的、对应的采购单是哪张、当时单价是多少”整个团队对着数据库查了半天也没给出完整链路。1.2 REA 的核心三件套资源、事件、主体REA 模型的思路非常朴素它不关心“单据状态”只关心“发生了什么事实”。任何业务都可以拆成三样东西资源Resource有价值、可识别的对象比如库存商品、现金、应收账款、积分。事件Event在某个时点发生的、改变了资源状态的事实比如出库、入库、收款、付款。主体Agent参与事件的人或组织比如客户、供应商、业务员、仓库管理员。用生活里的例子类比就是你网购一部手机手机是资源下单付款、快递签收都是事件买家和商家是主体。这套模型的厉害之处在于它允许你把“结果”丢掉只保留“事实”。余额、库存、应收应付这些指标全部可以由事件推导出来。我在动手之前给团队讲了一个很直白的比喻银行不会存“张三当前余额是 1000 元”这一个值银行存的是存款流水和取款流水你要查余额就把流水加起来。REA 模型本质上也是这个思路所有状态都不是存出来的而是算出来的。1.3 双事件原则一收一发才算完整事实REA 模型中还有一个容易被忽略的约束叫“双事件原则”。一个完整的业务事实通常由两个成对事件组成一个事件让资源流进系统另一个事件让资源流出系统。举个例子零售销售是“商品出库”加上“收到现金/形成应收”采购到货是“商品入库”加上“确认应付/支付货款”。这两个事件必须成对出现业务链条才是完整的。如果只记录了销售出库却没有对应的收款或应收事件那这个销售在财务上就是悬空的后面对账一定会乱。我在设计时就借助状态机约束保证一个业务过程要么两个事件都建立要么都不建立不允许只写一半。这样做的最大好处是所有状态变化都有因果链条任何一个数字都能顺着事件反推到源头。这不是为了理论好看而是为了在审计对账时能从余额走到单据、从单据走到参与人、再走到业务动作形成完整闭环。2. 建模落地的关键决策先分清事实与衍生状态2.1 盘点核心业务与实体清单项目代号rea的这套系统业务范围不算大主要是商品资料、采购入库、销售出库、库存调整、收付款、往来单位。真正建模之前我没有急着画表而是先盘了一遍业务过程中到底有哪些“资源、事件、主体”。整理出来的实体清单大概是这样的类型实体说明资源商品、库存、货币、应收、应付有价值且可被跟踪的对象事件采购入库、销售出库、收款、付款、盘点调整业务中发生的动作主体供应商、客户、业务员、仓库、财务人员参与事件的个人或组织这里有一个很容易忽略的点库存本身是一个资源而“库存余额”是一个衍生状态。建模时只需要记录每一次入库出库事件涉及商品的数量和方向即可不要单独建一张“库存实时余额表”作为主表。余额可以由事件流动态计算也可以做成缓存但缓存永远不能反过来覆盖事实。2.2 为什么付款和入库要拆成两个事件传统系统九十喜欢把“采购”做成一整张单据表供应商、商品明细、应付金额、付款状态全塞在一起。界面看起来好用但业务上会把两个不同时间发生的事实搅在一起。货可能到了款还没付也可能款付了货还没到。如果都放在同一张表里字段状态就会非常混乱。拆成独立事件后每个事件都有自己的发生时间、参与主体、资源变化。采购入库事件记录“货进来了”应付事件记录“我们欠供应商钱了”付款事件记录“钱付掉了”。后面想查“这批货对应的付款状态”只需要沿着事件之间的关联关系去查而不是去读取一个被反复覆盖的状态字段。这个拆分的牺牲是写数据的时候要多插几条记录界面要多做一次关联查询但换来的是业务表达能力的巨大提升。尤其是财务关账、审计对账的时候区别非常明显。团队里后来也统一了一个认知界面上看起来像一张单子数据库里其实是多个的事实组。2.3 新手最容易踩的建模错误直接在资源表上存状态我第一次尝试 REA 建模时犯过一个初级错误建好资源表后顺手给商品资源加了一个“当前库存数量”字段。每次录入库存事件后去更新这个字段感觉数据查询很直接结果问题一个接一个。退货要冲销时不知道该不该同时改字段盘点差异要调整时又要再写一个“调整数量”出来最麻烦的是想查“一个月前的库存”根本没有办法因为当前数量字段只保存最后的结果。正确的做法是资源表只存商品的静态信息库存数量一律通过库存事件推导。想查历史库存就过滤发生时间小于目标日期的事件再求和。后来我为了查询性能每天定时把库存快照计算出来丢进一张临时表做加速但快照只是缓存业务事实永远以 event 为准。谁要是手痒直接去 UPDATE 资源表里的状态字段我都建议他忍一忍回到事件流上想办法。3. 动手实现表结构、事件写入和状态推导3.1 核心表结构设计我直接把我设计好的核心表结构简化后贴出来这套结构支撑了项目里的库存和往来模块。注意这不是唯一方案但它是我在实际项目中验证过、踩过坑之后形成的稳定版本。-- 资源表存放商品、货币等有价值对象 CREATE TABLE resource ( id BIGINT PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(50), unit VARCHAR(20) ); -- 主体表客户、供应商、员工等 CREATE TABLE agent ( id BIGINT PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL ); -- 事件表记录业务事实 CREATE TABLE event ( id BIGINT PRIMARY KEY, event_no VARCHAR(50) UNIQUE NOT NULL, event_type VARCHAR(50) NOT NULL, occurred_at TIMESTAMP NOT NULL, biz_date DATE NOT NULL, remark VARCHAR(500) ); -- 参与关系事件与主体之间的多对多 CREATE TABLE participation ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(50) NOT NULL, UNIQUE (event_id, agent_id, role) ); -- 库存资源流事件改变资源的方向与数量 CREATE TABLE stockflow ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, direction VARCHAR(10) NOT NULL, -- in 或 out unit_price DECIMAL(18,4) );事件表里我没有放冗长的自定义字段而是通过 event_type 去对应不同业务的“扩展信息表”。比如销售出库事件需要关联客户订单号我就单独建一张event_sales_extra采购入库事件需要关联供应商送货单就建event_purchase_extra。这种设计避免把所有字段都塞进一张“万能事件表”否则事件表迟早变成数据库里的垃圾堆。3.2 如何记录一次完整的销售出库并收款举个例子客户 A 下单购买商品 P 十件单价一百元当天货发出去同时收到银行转账。按 REA 的玩法这应该是两个事实商品出库事件、收款事件。我用事务把它们一次写入START TRANSACTION; -- 1. 记录商品出库事件 INSERT INTO event (event_no, event_type, occurred_at, biz_date, remark) VALUES (EV20250618001, SALES_DELIVERY, NOW(), 2025-06-18, 销售出库); -- 2. 记录收款事件 INSERT INTO event (event_no, event_type, occurred_at, biz_date, remark) VALUES (EV20250618002, PAYMENT_RECEIVE, NOW(), 2025-06-18, 收到转账); -- 3. 出库事件的资源流商品 P 出库 10 件 INSERT INTO stockflow (event_id, resource_id, quantity, direction, unit_price) VALUES (1, 1001, 10, out, 100.00); -- 4. 收款事件的资源流货币 增加 总额 INSERT INTO stockflow (event_id, resource_id, quantity, direction, unit_price) VALUES (2, 2001, 1000.00, in, 1.00); -- 5. 参与关系客户 A 参与出库事件角色是收货人 INSERT INTO participation (event_id, agent_id, role) VALUES (1, 5001, CUSTOMER); INSERT INTO participation (event_id, agent_id, role) VALUES (2, 5001, PAYER); -- 6. 建立两个事件的关联形成业务闭环 INSERT INTO event_link (event_id, linked_event_id, link_type) VALUES (1, 2, EXCHANGE); COMMIT;这里我额外加了一张event_link表用来把成对事件关联起来。订单完成后销售出库和收款就能互相找到对方后续做对账非常方便。要强调的是这张关联表在最初的版本里根本没有后来是财务那边要求“每个销售都要能找到对应收款”我才加的。现在它是系统里最重要的表之一。3.3 库存和应收账款怎么推出来库存余额是最典型的推导场景。我对“实时库存”的查询 SQL 很简单统计某个资源所有入库事件的数量减去所有出库事件的数量。SELECT r.code AS resource_code, r.name AS resource_name, COALESCE(inflow.qty, 0) - COALESCE(outflow.qty, 0) AS current_stock FROM resource r LEFT JOIN ( SELECT resource_id, SUM(quantity) AS qty FROM stockflow WHERE direction in GROUP BY resource_id ) inflow ON inflow.resource_id r.id LEFT JOIN ( SELECT resource_id, SUM(quantity) AS qty FROM stockflow WHERE direction out GROUP BY resource_id ) outflow ON outflow.resource_id r.id WHERE r.category PRODUCT;只要你已经记录了“商品出库”事件和“收款”事件应收账款就等于销售出库事件的总金额减去收款事件的总金额。不需要一张“应收余额表”去反复更新。查询历史库存时只要在事件表上增加“日期小于目标日期”的过滤条件然后重新聚合即可。当然事件流直接聚合在数据量大时会有性能风险。所以我在项目里引入了每日快照每天凌晨跑一次全量库存和应收汇总存到快照表。日常页面查询走快照审计或对账需要精确数据时再走实时事件计算。快照只用于加速查询人力修改快照是绝对禁止的快照坏了就重新跑一遍聚合来恢复。4. 打通财务总账让事件自动变成凭证4.1 从 REA 事件到借贷分录的映射REA 模型如果只用于库存管理价值其实没有完全释放。真正让它发挥威力的地方是将业务事件自动翻译成财务凭证。传统系统里业务人员录一张销售单财务人员再手工做一张凭证两边数据还经常对不上。用了 REA 之后我让凭证生成直接与事件挂钩事件一变凭证也同步生成。映射逻辑大概是这样的业务事件借方贷方金额来源往来单位来源销售出库应收账款营业收入stockflow.quantity * unit_priceparticipation 中 CUSTOMER 角色收款银行存款应收账款stockflow 中 directionin 的金额participation 中 PAYER 角色采购入库库存商品应付账款stockflow 中 resource 的商品成本participation 中 SUPPLIER 角色付款应付账款银行存款stockflow 中 directionout 的金额participation 中 PAYER 角色我一开始犯过错误想着直接拿 event 表当作凭证表省一步。后来发现不对。同一个销售出库事件按不同会计准则、不同报表口径生成的凭证科目可能不一样。比如存货核算用移动平均还是先进先出、收入确认按发货还是按签收解释规则完全可能不同。所以我把“凭证”设计成独立的一层。业务事件是底层事实凭证是上层解释。做了凭证模板配置表后财务改科目映射业务代码一行都不用动。这一点在系统上线以后帮了大忙财务调整科目时不再需要提需求排队等开发改代码自己配模板就行。4.2 事件分类好凭证模板才能简单为了让凭证自动生成变得简单我在事件上加了两个字段一级事件类别比如销售、采购、收款、付款、盘点和二级事件类别比如销售出库、销售退货、销售折扣。凭证模板按“一级事件类别 二级事件类别”去匹配科目。比如销售出库是借应收账款、贷营业收入销售退货是借营业收入、贷应收账款。两者方向刚好相反但代码逻辑是完全一致的。这个设计让我轻松覆盖了红冲、折扣、部分退款等场景而不需要写一堆 if else。贴现处理也只需要新增一个二级事件类别然后配一套凭证模板即可。整个系统的扩展成本因此低了很多。4.3 对账差异的处理经验上线初期财务那边反馈最多的就是对账差异。我逐个排查后发现绝大多数差异不是模型错了而是事件里缺少财务所需的信息。比如销售单里含税价和不含税价都有但事件流里只存了一个单价财务没法拆分税额又比如采购折扣在单据层有记录但事件流里没有凭证生成时就漏掉了折扣金额。后来我在事件扩展表里增加了税额、折扣、含税原币金额这些字段凭证模板负责决定这些字段进哪个科目。这让业务层和财务层的数据能对上。另一个经验是不要为了保持模型的纯洁性而不存业务字段。事件的核心职责是记录事实事实里本来就包含税额、折扣、单据号等细节。存得越完整后续所有层级的处理就越有底气。5. REA 实践中的避坑指南与问题排查5.1 高频问题速查表问题现象可能原因解决方案事件表查询越来越慢缺复合索引多关联查询无缓存对 biz_date、event_type 建复合索引热点查询走快照库存数据被“修正”后对不上账直接在资源表更新了库存数量禁止更新资源状态一切通过事件流处理成对事件漏掉一个业务代码散落多处缺少约束用事务保证成对写入并用状态机检查单据日期和业务日期混乱只存一个时间字段区分 occurred_at事实发生时间和 biz_date业务归属日期过度泛化所有业务塞同一个事件event_type 划分不细按业务场景拆二级事件类别扩展表放细节5.2 三条很实用的操作纪律第一是事件不可变。一旦写入事件表的数据不允许 UPDATE 和 DELETE。业务错了就做一笔“冲正事件”把原来的错误事实用反向事实覆盖影响。这是事件溯源思想的基础也直接保证了审计链路永远完整。刚开始团队很不适应后来习惯了反而轻松因为不需要担心有人半夜偷偷改数据。第二是展示层再做聚合。库存余额、应收余额、销售统计全部都是聚合结果不是原始事实。代码层禁止将聚合结果写在业务事件写入链路里。数据要展示时再计算或者通过定时快照加速查询。这条纪律让系统在业务规则变化时依然不需要改动底层数据结构。第三是保留业务原样单据号。事件表里一定要有原始单据号、外部流水号字段。对账的时候业务人员拿的是系统外的单据号来问没有原样单据号就只能靠猜。这个字段看起来不起眼实际上能省掉大量查询和沟通成本。5.3 两个真实踩过的坑第一个坑是“数量正负号”问题。我一开始把出库事件的数量存成负数入库存正数然后统一按 SUM(quantity) 来算库存。结果某天盘点调整时有人录了一个反向调整又把方向理解错了导致库存差了一大截。后来我改掉了这个设计quantity 永远是正数方向单独用 direction 字段保存。所有聚合逻辑先按方向过滤再求和逻辑清晰了很多也不会再出现负数相乘这类低级错误。第二个坑是权限控制。一开始我考虑把权限挂在资源上比如某个角色可以看“某个商品”的数据改动。结果发现不同角色需要的不是资源维度而是事件参与维度。业务员应该只能看到他参与过的销售事件仓库主管应该看所有仓库相关事件。把权限挂在 participation 上后权限判断就变得非常简单查事件时带上“当前主体是否参与了该事件”这个条件即可。这也是 REA 模型带的意外收获因为参与关系本身已经被建模得很清楚了。最后分享一点实际体会这套 REA 模型做下来我最大的体会是模型本身并不复杂难的是坚持“只记录事实不记录结论”这个纪律。团队里每次有同事说“直接在库存表上加个字段多方便”“直接在销售单上改个状态不就完事了吗”我都会拉回到事件流上重新想一遍。事实证明短期内直接更新结果确实省事但系统每跑一个月积累的模糊地带就会多一层最后审计和财务就会受罪。我再分享一个小技巧REA 模型的适用范围远不止库存和财务。优惠券余额、积分账户、合同履约、工单流转本质上都可以拆成资源、事件、主体。只要涉及“谁在什么时候用什么资源做了什么”这类业务这套建模思路都能直接套进去。后续如果要把系统扩展到营销和合同模块我建议先从积分和履约事件入手把事件流跑通后余额和状态导出反而是顺手的事。

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

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

免费获取报价 →
↑