进销存系统改造实践:REA建模让对账不再靠Excel

发布时间:2026/10/10 14:29:01
进销存系统改造实践:REA建模让对账不再靠Excel
前阵子接手一个进销存系统的改造原项目代码量不小但每次月底对账都要靠人去拼Excel库存台账和财务账之间永远差着那么几笔。折腾到第三个通宵的时候我忽然想起一个被同行安利过很多次、但一直没认真研究的建模思路——代码里那个代号“rea”。把项目名敲进去资料越翻越觉得早该把它捡起来。“rea”在我做的这个项目里指的不是什么前端框架也不是某个开源库而是一套老牌的会计信息系统建模抽象Resource资源、Event事件、Agent代理。这套模型做进销存、订单中心、财务核算这类偏业务数据平台的项目解决的可不只是“表怎么设计”的问题它能把一群人的思维从“这张表存什么字段”拽到“业务到底发生了什么”。这篇文章我就用一次真实改造经历把“rea”从建模理论讲到建表落地你拿来当参考或者直接改一版应该都能用上。1. 从一场对账事故说起传统“科目表”思维到底错在哪1.1 旧系统月末对账的混乱现场原系统其实很有代表性单据表、凭证表、库存表、流水表各表之间靠业务编号硬关联。平时录入看着没什么毛病一旦出现退货、部分付款、跨月发货、调价补差表里的数据就开始互相“打架”。典型场景是某客户先预付了30%订金过了两周又付了50%最后发货后又补了余款。旧设计里财务在收款单上记一条流水仓库在发货单上扣库存订单模块再更新一下“已付金额”字段。三套逻辑各自为政月底发现订单金额对不上谁也不知道是收款漏记了还是发货数量没扣对只能翻原始凭证人工比对。这里的问题不是“人的责任心”而是底层建模思路选错了。多数系统喜欢按表单建模收款单、发货单、入库单、发票每张表单对应一张表表里的状态位记一堆“是否已对账”“是否已入账”。表单主导的结果是同一件业务事实被复制到了多个地方每个地方都认为自己才是“真账”于是冗余、滞后、矛盾成了必然。1.2 会计理论的“复式记账”救不了IT系统有人会说我们用了复式记账法借方贷方都记总不会错吧。复式记账在会计凭证维度确实是严谨的但它回答的是“钱从哪里来、到哪里去”没有回答“这笔交易背后对应的库存资源、履约事件、责任代理是谁”。更麻烦的是传统复式记账系统里利润表、资产负债表是直接从科目余额算出来的科目一多中间只要有一笔串科目月底就要试算平衡来救火。数据库本身不会帮你校验业务真实性科目表也承载不了库存资源的生命周期。我当时看着那一堆违规冗余的字段脑子里只有一个判断这套系统的数据模型没有围绕“事件”展开所有逻辑都围绕着“单据状态”打转。单据状态本质是人为定义的阶段变量它把事实变成了可变的状态而状态一多系统就失控了。1.3 “rea”想做的是把事实沉淀为不可变的事件流“rea”建模的核心主张其实很朴素别问“这张表该存什么”先问“业务里实际发生了什么”。库存减少了是一次销售事件钱进来了是一次收款事件产品从供应商那里拿到手是一次采购事件。这些事件是业务事实它们带上时间、数量、价格变成数据模型里真正不可再拆的原子。而库存数量、账户余额这类会被反复计算的结果不是“真实”数据只是事件的投影。换句话说传统系统把“余额”当成表里的字段REA把“余额”当成查询时算出来的结果。看起来只是设计习惯不同实际运维体验天差地别因为事件不可变余额可随时重建出问题的概率和排查成本都大幅下降。2. 三个核心构件拆开揉碎资源、事件、代理如何互相咬合2.1 资源Resource不是“库存表”而是有价值的可识别对象没有经验的人常把Resource理解成商品SKU咬文嚼字地讲它应该是“在业务中具有价值、可以被买卖或消耗的对象”。具体到进销存里同一件商品在“可用库存”“在途库存”“冻结库存”中流动你不需要把每种形态都设计成独立资源表而是用一个资源主数据加若干与事件关联的存量记录来表达。关键是资源必须具备可计数性和可估值性单一资源实例要有唯一标识比如某个批次、某张充值卡、某个维修工单。我习惯把资源表拆成两层一层是静态主数据商品名称、规格、默认成本另一层是动态资源实例批次号、所在仓库、当前状态。这样做的好处是当财务说“这个月这批货的成本要调整”时动的是主数据而当仓库说“这批货挪走了”动的是资源实例关联的事件记录两件事互不污染。2.2 事件Event的本质是带时间戳的“业务事实”事件是三个构件里最重要的一项也是当代事件驱动架构的天然亲戚。每一次出库是一次“发货事件”每一次客户付款是一次“收款事件”每一次退货是一次“退货事件”。判断一个动作算不算事件最简单的办法是问它是否造成了资源或代理之间价值的转移如果只是把订单状态从“处理中”改成“已确认”那就不算业务事件它只是流程状态。事件需要把时间戳作为一等公民。旧系统里的“单据日期”往往被用户输入成任意值而REA模型里事件发生时间应该优先取系统时间业务时间单独作为辅助字段。数据模型上事件表一般会包含事件类型、发生时间、关联资源ID、数量、单位、单价、参与代理ID、对应的流程引用编号。这里有个容易犯的错把多个动作塞进一个事件。比如“一次销售同时完成发货”有人就会设计一张“销发单”库存也扣了销售也记了。REA鼓励把事件拆开销售是“承诺/交付”环节事件发货是“物流”环节事件二者通过关联字段连接而不是合并成一张大宽表。拆分之后部分发货、部分退货、分批收款都能自然表达。2.3 代理Agent不只是“客户”更包括内部的“责任人”Agent在任何一笔事件里都有两个角色内部代理自己公司的某人或某部门和外部代理客户、供应商、物流商。一个销售事件至少有销售员和客户两个代理参与一个采购事件有采购员和供应商参与。把这些代理关联到事件表上后续做员工业绩考核、供应商评估都会容易不少。更讲究一点还会把Agent再细分成“责任主体”和“控制主体”。责任主体承担事件产生的资产负债变化控制主体负责对资源的实际使用。比如仓库管理员可以控制系统里的库存资源数量但库存所有权的责任主体是公司财务两者都记录在代理实体里权限边界才不会被一个“库管管理员”字段糊弄过去。2.4 五种基础关系从“资源→事件”到“代理→事件”建模时记住五条关系基本就把ER图能画出来了资源流入事件比如原材料转化为产品多个输入资源对应一个生产事件。资源流出事件比如销售出库一个事件流出若干资源实例。事件触发事件比如收款事件触发了发货事件用“关联事件ID”连接。代理参与事件一个事件有外部代理和内部代理两个参与方。控制关系代理控制资源比如库管员控制库存财务控制资金账户。对于库存数量不需要在“库存表”里不断减减加加而是从“资源流出事件”里SUM出去的数量减去“资源流入事件”里SUM进来的数量。这样一旦数据有出入直接查事件明细而不是查余额谁改错了。3. 用一个进销存模块做实例从事件清单到数据表落地3.1 第一步先列事件清单不急着建表我在动手建表之前会先约业务方坐下来把“钱货两清”的全过程拆成至少六个事件客户提交订单承诺事件同时也是一个销售事件的前置动作仓库发货资源流出事件货离开我方仓库物流送达代理转移资源从物流代理转给客户客户验收确认外部代理确认事件资源所有权转移客户付款资源流入事件资金流入我方账户财务确认收入内部代理确认事件生成应收/实收记录这一步的产出是一张Excel表格事件名称、事件类型、参与内外部代理、涉及的资源、触发条件、可选的关联事件。建表之前花两小时干这个后面能省两个礼拜的返工时间。3.2 第二步按“ea”的规则映射成五张核心表我把这六类事件抽象成事件主表再搭配资源、代理、资源-事件明细和代理-事件关系表。看起来表数量比以前还多实际上每张表职责单一联查起来反而简单。下面是简化后的建表思路-- 事件主表 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32), -- SALES_DELIVERY / PAYMENT / PURCHASE / RETURN event_time TIMESTAMPTZ NOT NULL DEFAULT now(), location_id BIGINT, -- 发生地点或仓库 related_event_id BIGINT, -- 关联的前置事件如发货关联订单 description TEXT, created_by BIGINT -- 内部代理 ); -- 资源表 CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, sku_code VARCHAR(64), resource_name VARCHAR(128), spec VARCHAR(64), is_batch BOOLEAN, -- 是否按批次管理 default_cost NUMERIC(12,2) ); -- 资源实例表批次/序列号/可用数量表态 CREATE TABLE resource_instance ( instance_id BIGINT PRIMARY KEY, resource_id BIGINT REFERENCES resource(resource_id), warehouse_id BIGINT, status VARCHAR(16), -- 可用/冻结/在途/报废 batch_no VARCHAR(64) ); -- 资源-事件明细表 CREATE TABLE resource_event_line ( line_id BIGINT PRIMARY KEY, event_id BIGINT REFERENCES event(event_id), instance_id BIGINT, quantity NUMERIC(14,4) NOT NULL, unit VARCHAR(16), unit_price NUMERIC(12,4), amount NUMERIC(14,2) ); -- 代理-事件关系表 CREATE TABLE agent_event ( relation_id BIGINT PRIMARY KEY, event_id BIGINT REFERENCES event(event_id), agent_id BIGINT, agent_role VARCHAR(16), -- INTERNAL / EXTERNAL duty_flag VARCHAR(16) -- 责任主体/控制主体 );你看既没有“库存余额表”也没有“应收账款余额表”。余额类数据全部通过SQL聚合事件明细得出状态位被压到了最少。3.3 第三步用代码示例演示“查询可用库存”的正确姿势查询某仓库当前可用库存不再是“查库存表里那个数”而是从事件明细聚合。下面这段Python演示了思路from datetime import datetime from sqlalchemy import func, select def available_quantity(session, resource_id, warehouse_id): inflow select( func.coalesce(func.sum(rel.quantity), 0) ).join( resource_event_line, resource_event_line.line_id rel.line_id # 简化关联 ).where( resource_event_line.instance_id.in_( select(instance_id).where(resource_id resource_id, warehouse_id warehouse_id, status available) ) )完整实现还要区分流入和流出SQL会比这个长一点但核心思想是在查询层实时计算而不是在写入层手动改库存数。有人担心实时聚合查询慢。第一现在数据库对这类带索引的聚合查询已经很快第二你完全可以在事件写入后异步生成一张“资源余额快照表”作为读模型缓存。但快照只是缓存不是主数据的源头这一条原则必须守住。3.4 第四步把“对账”变成“可重算的投影”换用REA之后月底对账的逻辑就变成了先把所有事件明细拉出来按代理、资源、时间范围分组再和对方发来的往来清单做比较。因为余额是投影什么时候发现不平什么时候重跑一遍查询就能查出是哪个事件漏了。我更推荐把对账规则也落成代码而不是每次写SQL。定义一个函数比如reconcile(agent_id, start_date, end_date)内部依次计算“应收累计”“实收累计”“发货累计”“验收累计”任何一组对不上就把对应的明细行打印出来。这个函数就是业务团队和财务团队在月底共同信任的那把尺子。4. 应用“rea”时避不开的五个坎来自真实项目的教训4.1 分不清“事件”和“承诺”设计从第一步就开始歪订单提交本身不是一个资源转移事件它是客户对达成交易的承诺。如果把订单当事件出库也算事件就会在数据里出现两条重复记录聚合时不知道拿谁当依据。建议的建模方式是把“订单”作为独立流程实体事件表里记录“订单被满足”的那个关键动作比如发货事件带上order_id。承诺可以作为考核指标统计但不进入库存和资金的资源流转计算。4.2 事件拆太细也会走火入魔REA强调事件原子化但原子化不等于碎尸万段。有一次我们把“客户点了一下确认收货按钮”也做成一个事件结果下游有三个统计报表要改业务部门反而不理解。事件拆分到“每个动作都带独立业务意义”的程度就够了按钮层面的点击行为交给前端埋点别塞进核心事件表。4.3 金额字段不能随便挂一个常见纠结是金额写在事件行里还是资源上。我的做法是金额永远是事件的两个参与代理之间协商的结果所以金额字段挂在事件行上资源只记录数量不记录动态交易价格。比如同一批货物卖给A客户和卖给B客户的价格不同如果价格挂在资源主数据上就只能记一个标准价一调就影响历史数据。挂在事件行上历史价格天然固化审计时直接看事件明细就能回答问题。4.4 千万别用REA直接替换整个ERP的逻辑层REA更像一个领域模型的指导原则而不是功能清单。像采购审批流、合同管理、预算控制这些流程性功能还是要保留传统的工作流设计和状态机。REA管的是“业务事实如何沉淀”工作流管的是“流程步骤如何推进”两者可以共存。我把“状态”划分成两类一类是业务状态来自业务方确认一类是事实状态来自事件是否发生。状态存进流程表事实存进事件表互相不覆盖。4.5 没有配套的事件命名规范三个月后一样变糨糊事件类型必须是公司级统一词汇表要定义清楚比如事件类型业务含义参与代理资源方向SALES_DELIVERY销售出库销售员客户流出SALES_RETURN销售退货销售员客户流入PAYMENT_IN客户付款财务客户流入PAYMENT_OUT供应商付款财务供应商流出PURCHASE_RECEIPT采购入库采购员供应商流入没有这套规范开发A把出库事件叫OUT开发B叫DELIVER后面做数据治理的人会想杀人。我把这套词汇表直接写进了项目的enum定义里并在数据库约束层也加了CHECK。5. “rea”给我的团队带来的变化不止是建表方式5.1 开会讨论从“字段吵架”变成“事件对齐”业务说“我们要加一个预付款冲抵的功能”以前团队第一反应是“订单表加一个已抵扣金额字段”采用REA之后大家会说这是一个“预收款事件”和后续“抵用事件”之间的关联问题。业务方不一定懂数据库但他们懂“客户先给了钱后面这笔钱抵掉一部分货款”。把话题从“加字段”转成“加事件”普通产品和运营也能参与建模讨论需求评审会的效率明显上去了。这也是REA带给我预期外的最有价值的一点。5.2 数据修复变成“补事件”而不是“改余额”曾经有笔历史数据因为重复导入导致库存多出了50件。旧系统里只能写UPDATE去改库存表还要担心其他报表是否引用了同一字段。REA模型下我们只需要在事件表里补一条负数调整事件注明原因、操作人、审批编号然后重新跑一次聚合余额就自然修正了。所有操作在日志里可解释、可复查这让后来做内部审计的人省了不少心。我始终觉得“可解释的数据修正”比“正确但不可追溯的数据”重要得多。5.3 将来接数据中台和事件溯源几乎不用改底层REA和事件溯源Event Sourcing思路高度合拍资源、事件、代理三要素天然适配消息队列里的业务事件。我们后来把关键事件同步到数仓BI那边要做留存分析、复购分析事件表直接就是事实表代理表可以直接当维度表数据管道少写了一半转换逻辑。如果你正在计划搭建一个数据仓库或者准备把老系统往微服务方向重构在一开始就引入REA式的建模后面数仓建模会顺很多。我个人给新项目的默认建议就是核心业务域用REA画事件图周边业务流程用工作流状态机这样既有事实的硬度又有流程的灵活。再分享一个最近实践里的小技巧给每个事件写一句“一句话说明”字段让录入员用日常语言描述当时发生了什么。技术模型再严谨也抵不上现场一句“客户说不要了退回一半”直观。这行字能让三个月后的你少掉一把头发。