☰
REA建模实战:用资源事件参与者重构订单库存财务系统
2026/10/21 23:22:54 网站建设 项目流程

1. 放着一张现成的凭证表不用,我为什么绕回去用REA建模

说个我最近大半年一直在折腾的事。我给一家做小批量贸易的团队维护过一套订单、进销存加财务核算的系统,第一版的核心极度传统:一张会计凭证行表,字段无非是日期、科目、借方金额、贷方金额、摘要,再加一张科目余额表。这套结构放在教科书里挑不出毛病,出利润表、资产负债表也顺畅。可真正跑起来才发现,业务部门要的远不止这两张报表——一笔销售出库,在凭证表里是一条“借:应收账款 / 贷:主营业务收入”的分录,在库存模块里要再解释一次“库存减少”,在客户对账页面还得再解释一次“客户欠款增加”。同一条经济事实被不同模块各解释一遍,三套数据各自为政,每周光是对账就耗掉大半天。

我被逼到墙角,只好重新审视整个数据模型,最后落地的方案就是这次想聊的REA建模思路:Resource(资源)、Event(事件)、Agent(参与者)。这套模型在会计信息系统领域里其实已经有了几十年历史,只是一直以来偏学术,很少有人把它的落地细节摊开讲。它的核心思想一句话可以概括:先记录客观发生了什么,再从这个事实身上派生出记账、库存、对账各种视角,而不是一上来就按借贷科目去解释业务。

拿买咖啡举例。你付了15元拿走一杯咖啡,传统记账会写“借:库存现金 15 / 贷:主营业务收入 15”,顺带结转一杯咖啡的成本。REA记录的是另外三个事实:顾客这个Agent参与了“收钱”事件,现金这个Resource因此增加;店员参与了“交付咖啡”事件,咖啡豆这个Resource因此减少;收钱和交付咖啡两个事件构成一组对应的经济交换。至于“收入该确认多少”“成本怎么结转”,那是之后派生出来的口径。这个思维转换,就是我这次重构里最关键的转折点。

这篇更多像一份复盘总结,不只是理论科普。我尽量把建模过程、表结构、业务流转和坑位都摊开,适合正在做ERP、订单系统、进销存、财务中台的开发者参考,也适合刚入门、好奇“为什么有些业务系统不直接照搬凭证表”的朋友。

2. REA三个核心概念拆开看:Resource、Event、Agent

2.1 Resource:能被识别、能被度量的资源

Resource这个词比我一开始理解的范围要窄。并不是所有有价值的东西都能成为REA里的Resource,它至少得满足两个条件:能被唯一识别,能被量化度量。现金、原材料、产成品、仓库里的打包纸箱、可计量的服务工时,这些都没问题。但“应收款”“应付款”这类东西我后来决定不设成Resource——它们更像两笔已发生事件尚未配对完形成的差额,是一种中间状态,而不是能握在手里的资源。把它们建模成资源,等于在每个客户头上挂一堆永远对不上的虚拟数量,反而乱了。

我第一次设计时犯过一个典型错误:把所有资源塞进一张resource表,靠resource_type区分现金、库存、服务。结果现金流水和库存流水混在一起,查询时处处写条件判断。后来还是拆开了,资源主表只保留公共字段,现金账户、商品档案、服务项目各用一张子表扩展。这个拆分和“一张大表vs多张明细表”的取舍在后面还会反复遇到。

2.2 Event:事件才是唯一允许动数据的地方

REA里真正能改变资源数量的只有Event。它表达的是经济利益的流入或流出,收钱是事件,发货是事件,原料进车间是事件。事件发生时间、事件类型、参与方这些字段是它的骨架。

这里有个特别容易混淆的地方:订单到底算不算事件?我的结论是,采购订单、销售订单这些“尚未执行、只是双方同意”的东西不算Event,它们属于Commitment(承诺)。订单是对未来事件的预约,一旦实际执行,才会产生收货、发货、收款、付款这些真正的事件。把订单和执行业务塞在同一张表里,你会发现部分发货、分批到货这类常见业务会让你恨不得给一张表加十个状态字段。

我也会在事件上保留“引用来源”的线索。比如一张采购订单对应两次收货事件,两次事件都挂着同一个commitment_id,这样追溯起来就是一条完整的链:承诺——执行——资源变动——账务结果。很多系统烂就烂在链路断点太多,财务、仓库、销售各看各的。

2.3 Agent:把“谁”放到模型里,而不是塞进备注字段

Agent是参与经济事件的个人或组织。客户、供应商、员工,以及公司自己,都算Agent。很多人建表时会把客户和供应商各建一套,甚至用“往来单位”一个名字糊弄过去,但同一个主体可能既是客户又是供应商,这种业务场景真实存在。REA的做法是把所有参与方统一落到agent表,用agent_type区分角色,再用事件上的参与方角色属性表达“这笔销售里他是买方”还是“这笔采购里他是卖方”。

还有一层关系容易被忽略:Agent对Resource的控制关系。现金不会凭空属于公司,它归属在某个Agent的账下。把公司自身建模成Agent之后,内部员工领用、部门间调拨才能自然表达——不然你很难说清楚仓库里的货从法律意义上到底属于谁,内部流转记录也容易变成一笔糊涂账。

2.4 Duality:经济交换的另一半必须成对出现

Duality是我认为REA里最值钱的概念。每一场完整的经济交换,必须有一增一减两个事件配对对应:销售发货对应收钱,采购收货对应付钱,生产领料对应成品入库。只记录“发货”不记录“收钱”,系统无法判断这笔应收是没收还是没收全;只记录“采购收货”不记录“付款”,应付账款同样说不清。

落到表结构上,我用了一张event_link表来维护配对关系,把increment_event_id和decrement_event_id成对挂起来。配对关系不是派生的,而是在业务发生时顺手建立的:确认销售订单、财务确认收款之后,系统把两个事件关联起来。回头查账时,一条事件就能顺着link找到它的另一半,审计线索非常干净。

为了便于理解,我常把REA概念和传统会计借贷放在一起看:

REA概念业务例子对应会计处理
Resource库存商品、现金资产类科目
Event-Increment收货、收钱借:库存 / 银行存款
Event-Decrement发货、付钱贷:库存 / 银行存款
Duality销售事件对收款事件借贷方向相反的成对分录
Agent客户、供应商、员工、本公司辅助核算/明细科目
Commitment采购订单、销售订单不在凭证层体现,挂在备查簿

2.5 落到数据库:我实际用的表结构

说再多概念,不如直接看表结构。我最终落地时用的是这份精简版设计:

CREATE TABLE resource ( resource_id BIGSERIAL PRIMARY KEY, resource_type TEXT NOT NULL, -- cash / material / product / service name TEXT NOT NULL, unit TEXT NOT NULL DEFAULT 'ea' ); CREATE TABLE agent ( agent_id BIGSERIAL PRIMARY KEY, agent_type TEXT NOT NULL, -- customer / supplier / employee / company display_name TEXT NOT NULL ); CREATE TABLE event ( event_id BIGSERIAL PRIMARY KEY, event_type TEXT NOT NULL, -- purchase_receipt / sales_shipment / cash_receipt / cash_payment occurred_at TIMESTAMPTZ NOT NULL, counterparty_id BIGINT REFERENCES agent(agent_id) ); CREATE TABLE flow ( flow_id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES event(event_id), resource_id BIGINT NOT NULL REFERENCES resource(resource_id), direction TEXT NOT NULL CHECK (direction IN ('IN', 'OUT')), quantity NUMERIC(18,4) NOT NULL, unit_price NUMERIC(18,4) ); CREATE TABLE event_link ( link_id BIGSERIAL PRIMARY KEY, increment_event_id BIGINT NOT NULL REFERENCES event(event_id), decrement_event_id BIGINT NOT NULL REFERENCES event(event_id), link_type TEXT NOT NULL DEFAULT 'exchange' ); CREATE TABLE commitment ( commitment_id BIGSERIAL PRIMARY KEY, commitment_type TEXT NOT NULL, -- purchase_order / sales_order agent_id BIGINT REFERENCES agent(agent_id), resource_id BIGINT REFERENCES resource(resource_id), quantity NUMERIC(18,4) NOT NULL, status TEXT NOT NULL DEFAULT 'open' );

flow表是resource和event之间的桥,表达“这场事件让哪个资源流入了多少、流出了多少”。真正的数量记账全在这里,event表只负责“何时何地发生了什么”,不直接塞数量,这样事件的复用性更高。比如生产领料和销售发货都是资源流出,但事件类型不同,通过flow就能把它们区分开。

3. 采购、销售、库存怎么用REA串成闭环

3.1 采购循环:从承诺到收货再到付款

采购业务在REA里可以拆成三段。

第一步,采购承诺。在commitment表里插一条purchase_order,关联供应商Agent和原材料Resource,数量100件,状态open。此时仓库、财务都不需要动,只是有了一个“未来要发生”的预约记录。

第二步,收货事件。实际到货50件时,系统创建一条purchase_receipt事件,同时在flow表里插入一条方向为IN的记录,数量50件。这50件原材料立刻进入库存余额。如果只看这一步,会发现公司多了一批货,但没有付出钱,这就是应付账款的雏形。

第三步,付款事件。过了账期财务付款,创建cash_payment事件,在flow里记录cash资源方向为OUT、金额等于货款。此时把purchase_receipt和cash_payment通过event_link关联起来,一对多还是多对多都能挂。

这三次操作完成后,采购闭环就闭合了。库存、应付、现金三条线全部对齐,任何一个环节的数据源头都是事件表,对账时再没有“哪个模块写错了”的甩锅空间。

3.2 销售循环:发货与收款的配对

销售循环是采购循环的镜像,只是方向相反。

销售订单同样先进commitment;实际发货时创建sales_shipment事件,在flow里记录库存商品方向为OUT;财务确认到账后创建cash_receipt事件,在flow里记录cash方向为IN。两个事件通过event_link建立配对,应收账款的存在与否,本质上就是“发货事件已经发生,但还没有找到配对的收款事件”。

这个模型处理“预收款”也很有意思。客户预付一笔钱,系统先创建cash_receipt事件,此时它还没有对应的sales_shipment。这里的配对关系先挂到一个中间状态,待发货后再补充关联。传统手工账里要做预收账款科目来回调整,REA模型里直接看事件配对状态就够了。

3.3 从事件流生成借贷分录的规则

纯REA事件流不能直接当会计凭证用,得加一层转换规则。我维护了一个“事件类型到会计分录”的映射配置,原理是:资源流入方向对资产类科目记借方,资源流出方向记贷方,同时把对偶事件的差额计入收入、成本、往来科目。

# 简化的映射逻辑示例,真实项目里会结合科目表做配置 def to_journal_lines(event, flow): if flow.direction == 'IN': return ("借", resource_account(flow.resource_id), flow.quantity * flow.unit_price) else: return ("贷", resource_account(flow.resource_id), flow.quantity * flow.unit_price)

真正的系统里远没有这么简单:销售事件的流出要按成本价结转,同时按售价确认收入,光这一条就需要成本计价规则的介入。但核心思想是,凭证不是录出来的,而是从事件流推导出来的。推导规则固定下来之后,报表口径反而稳定了,因为底层业务事件不会因为会计政策调整而篡改。

4. 落地半年踩出来的坑,以及我的补救方式

4.1 成本口径不是REA的职责

第一个大坑是我误以为REA能顺便告诉我“这批货值多少钱”。实际上REA搞清楚的是数量:进了多少、出了多少、还剩多少。但这批货按什么单价出库,是先进先出、移动加权平均还是个别计价,属于会计政策,不属于业务事件本身。

我见过有的设计把单价直接塞进flow表的unit_price字段,进货时用采购价,销售时用销售价,结果同一资源不同时点计价逻辑混乱,月底利润怎么算都不对。我的做法是额外维护一张cost_valuation表,专门记录每个资源在每一时点的数量、单价、计价方式,月底结账时用它重新核算库存成本,而不是依赖flow表里的原始价格字段。

4.2 退货:反向数量还是反向事件

退货这个场景差点让我推翻整个模型。一开始图省事,退货时在原事件上直接插一条负数量,比如发货100件,退回10件,就再插一条quantity=-10的flow。这个方案SQL写起来简单,但有一个致命问题:流水里的正负号混在一起,求和时看起来没事,一旦要按事件分页、追溯、审计,符号规则能把人逼疯,而且时间久了根本说不清是“初始发货的负数”还是“独立退货事件”。

后来我改成标准做法:退货就生成一个独立的、方向相反的逆事件。销售退回就生成sales_return事件,flow里direction=OUT改成IN,并在event表上加一个reference_event_id指向原始发货事件。这样审计线索完整,流水里每条记录的含义都清晰,查询余额时也不需要到处处理符号。

4.3 部分履约靠Commitment兜底

采购100件分三批到货,每批数量还不一样,这种业务在真实贸易里稀松平常。如果我只用事件表,Commitment的信息就保不住:订单承诺了多少、还剩多少没到货,全靠人工看表格猜。

commitment表在这里的职责是承载“预约数量”,事件表承载“实际执行数量”。剩余履约量就是:

SELECT c.commitment_id, c.quantity, COALESCE(SUM(f.quantity) FILTER (WHERE f.direction = 'IN'), 0) AS received, c.quantity - COALESCE(SUM(f.quantity) FILTER (WHERE f.direction = 'IN'), 0) AS open_qty FROM commitment c LEFT JOIN flow f ON f.resource_id = c.resource_id LEFT JOIN event e ON e.event_id = f.event_id WHERE c.status = 'open' AND e.event_type = 'purchase_receipt' GROUP BY c.commitment_id, c.quantity;

commitment的status字段我在open、closed之外保留了partial状态。只要open_qty大于0就自动置为partial,全部履约完成再置为closed,采购催单逻辑直接查这张表就行。

4.4 事件表别想一张表装天下

刚开始我把所有事件类型都塞进event表,采购、销售、收款、付款、生产领料、盘点调整全混在一起。表面上看统一好维护,实际查询时每个业务都要过滤event_type,字段还严重不足——销售事件需要关联销售订单号,生产事件需要关联批次号,把所有这些业务特有字段都堆在公共表里,最后就是一张顶部十几列通用字段、底部二十几列全是null的怪兽表。

我最终把event拆成两层:event公共表只保留event_id、event_type、occurred_at、counterparty_id这些所有事件共有的字段,再为采购、销售、生产、资金各建业务子表,通过event_id回指公共表。这样查询销售明细时只需要查销售子表,不用背着库存、生产的无关字段跑。

4.5 报表别直接在底层模型上跑

REA的正规化模型对写入和追溯特别友好,但报表查询就惨了。只要涉及余额统计,就要把event、flow、resource、agent挨个join一遍,数据量大时一个累计余额查询能拖好几秒。我刚开始还天真地以为数据库能抗,等到客户要求按天查库存趋势时,后台直接超时。

解决办法是物化视图加快照表。每天的期末余额独立落一张balance_snapshot表,记录资源、时间、流入总量、流出总量、结余数量,报表全部走快照,不再回头join底层事件。快照和事件流之间定期做对账校验,对不平的那天自动告警,数据质量反而比原来手工核对时更可控。

5. 什么项目适合做REA,什么项目别硬套

5.1 适合用REA的场景

如果你在做的系统同时要服务三个以上模块,比如订单、库存、财务、客户对账都要从同一套数据出发,REA那套“记录经济事实、统一派生口径”的思路非常合适。它最大的价值不是省存储,而是让数据只有一个事实来源,所有部门看到的都是同一份事件流推导出来的结果。

订单履约链路比较长的行业也适合,比如多批次出入库、部分交付、多次收款、月结客户这些场景。commitment加event的两层结构,能把“承诺了什么”和“实际发生了什么”分开管理,这种清晰度是传统借贷凭证表很难给的。

5.2 不适合用REA的场景

相反,如果只是做一个简单的个人记账工具,进账、出账、看余额三件事,一张流水表加一个聚合查询就够了,硬上REA只会把简单问题复杂化。还有高并发的纯交易系统,比如积分账户、支付流水,这些场景按账户行式表设计读写路径最短,吞吐量最高,REA那套多表join模型反而拖后腿。

团队全都是传统会计出身、坚持按会计科目体系建模块的,强行引入REA也需要慎重。模型本身不会和凭证体系冲突,但团队认知切换成本高,落地过程中很容易陷入“这不就是凭证表吗”的巨大争论里。

5.3 我最终采用的混合方案

折腾到最后,我并没有执念地搞“纯REA”,而是让REA做业务骨架,凭证系统做输出层。底层业务侧跑event、flow、commitment,财务侧维护科目表、记账规则和凭证需求,谁需要凭证,就由规则引擎从事件流实时推导,推导结果落进一张物化视图供报表引用。如此一来,仓库、销售、采购看到的都是业务事实本身,财务拿到的照样是不折不扣的传统借贷凭证,两边各取所需。

期末对账就在这张物化视图上跑:资产负债表两侧必须从同一事件流算平,任何一个不平都当成系统缺陷去查,而不是靠人工调账。这套混合结构的维护体验让我对REA有了全新认识——它不是替代凭证系统,而是把凭证系统变成众多输出视角之一。

最后再分享一个实操里的小经验:事件类型代码一旦上线,宁可新增、不要修改含义。我在生产环境里改过一次sales_shipment的事件类型定义,把“已发货未收款”和“已发货已收款”的状态塞进了同一种事件里,结果所有历史对账逻辑全部跟着错位,足足花了两周才把数据校正回来。事件类型是整套模型的地基,地基的语义一旦漂移,上面所有派生口径都会跟着晃动。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询