恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于RuoYi二次开发进销存系统:库存流水与表结构设计
首页
资讯中心
/
基于RuoYi二次开发进销存系统:库存流水与表结构设计
基于RuoYi二次开发进销存系统:库存流水与表结构设计
发布时间:2026/10/18 11:46:56
简介基于Java和RuoYi框架二次开发的KS进销存系统源码为小型经销商提供贴合自身业务流程的采购、销售、库存管理方案。系统特意弱化传统进销存软件的复杂逻辑强调简洁直观适合业务转型中的中小企业及希望学习实际项目结构的Java开发者。整个资源包共936个文件其中Java源码304个、Vue组件101个、JavaScript脚本149个另有SVG、PNG等素材及SQL、YML等配置压缩包仅10.94MB便于快速下载。当前已有837人学习使用。通过这套源码可完整了解RuoYi脚手架的改造方式、进销存核心功能的前后端实现以及项目打包与启动脚本的使用方法清晰的目录层级和多样化的文件类型也为二次开发和业务扩展提供了便利。1. 当业务负责人递来一份“三天上线”的进销存需求我劝你先想清楚库存这张底牌做过几个后台管理系统的人多半会觉得基于Java和RuoYi二次开发一个进销存系统只是“加几张表、写几个CRUD、套个模板”的体力活。我第一次接手KS进销存系统的设计源码时也这么想直到采购入库单保存成功、库存余额纹丝不动销售出库把库存扣成了负数我才意识到进销存和普通后台系统的本质区别库存不是一张列表而是一连串交易不断加减后留下的余额。你在界面上看到的是商品数数据库里必须有一张流水账记录每一次变动的原因、单据号和时间。这份KS进销存系统设计源码的核心价值不在于把界面做得多漂亮而在于如何用RuoYi提供的权限、字典、代码生成器这些底座把采购、销售、库存三个环节串成一条不丢数据、不产生负库存的完整链路。适合已经跑通过RuoYi框架、准备接手进销存或供应链类业务的人员来读。下面我从二次开发的边界、表结构、核心业务流程、高频踩坑四个维度完整拆一遍附带能直接落地的代码片段。2. 先认清RuoYi二次开发的边界框架白送什么进销存必须自己造什么2.1 RuoYi工程骨架先跑通这四层代码分别管什么事RuoYi的典型单体工程结构从上到下分四块admin是Web入口层放Controller和启动类framework是框架配置层放Shiro安全配置、拦截器、数据源配置system是系统模块层放用户、角色、菜单、字典这类通用业务common是公共工具层放统一返回结果、分页对象、Redis工具、异常处理等。做二次开发时如果你把进销存业务代码直接塞进system模块里项目初期跑得很快但随着采购单、销售单、客户、供应商这些表越来越多system模块会变成一个塞满业务代码的大杂烩。我的习惯是在工程里新建一个业务模块目录比如erp或biz它依赖system和framework但反过来不能被系统模块引用。代码如下ruoyi-admin/ # Web入口Controller、启动类 ruoyi-framework/ # 框架配置权限、拦截、数据源 ruoyi-system/ # 系统模块用户角色菜单字典 ruoyi-common/ # 公共工具分页、返回体、Redis ruoyi-erp/ # 进销存业务模块二次开发新增 ├── controller/ # 采购/销售/库存控制器 ├── service/ # 业务逻辑与事务边界 ├── mapper/ # 数据访问接口与XML ├── domain/ # 实体对象 └── vo/ # 前端交互对象比如采购明细列表这里的关键逻辑是RuoYi自带的system模块解决的是“谁能用这个系统、能看哪些菜单”的问题而进销存模块解决的是“用户的业务单据怎么流转、库存怎么变动”的问题。两者通过登录用户的ID和部门ID关联业务表里存create_by和create_dept字段这样权限控制和数据归属才能接得上。把业务模块独立出来后续如果要把进销存拆成独立的微服务或者和电商订单系统对接你只需要动ruoyi-erp这一个模块的代码。2.2 框架白送的六项能力直接复用别自己重写轮子RuoYi里可以直接拿来用的能力第一项是代码生成器。我在设计供应商、仓库这类基础档案的CRUD时先在数据库建好表再用生成器生成Java代码到ruoyi-erp模块然后把Controller改成继承BaseController分页查询用startPage()方法自动拦参数返回体用AjaxResult.success()包装。第二项是字典管理进销存里有大量“状态”“类型”字段比如单据状态有草稿、已审核、已出库商品分类有大类小类这些值经常要改千万别写死在代码里用DictDataSource注解或者SysDictData表管理前端下拉框自动加载。第三项是参数配置库存预警阈值、单据编号前缀、默认仓库等存到sys_config表用ConfigUtils.getConfigValue(erp.stock.warning)读取改配置不用重新部署。第四项是定时任务后面讲到的每日库存对账、过期订单自动关闭都靠Quartz定时任务实现。第五项是操作日志进销存里的审核、反审核操作必须留痕直接调SysOperLogService记录操作人和操作内容。第六项是部门数据权限但这项需要自己做增强后面避坑章节会重点讲。2.3 框架不提供的三件事单据流水、库存快照、统计报表RuoYi没有采购单、销售单、库存流水这些领域模型这不算缺点因为框架本来就不该绑定具体业务。真正需要你从零设计的是三件事。第一单据编号生成。进销存的采购单、销售单、出入库单都得有业务编号格式一般是“前缀年月日流水号”比如CG20250117001而且审核、打印、对账都要靠这个编号串联。RuoYi没有现成接口我用的方案是建一张erp_bill_number表每类单据占一行每次生成编号时用SELECT ... FOR UPDATE锁行更新流水号确保并发时不重号。第二库存快照。进销存界面里显示的“当前库存”在数据库里应该是一张erp_stock_balance表记录每个仓库下每个SKU的实时余额。这张表不参与业务单据的录入它只是库存流水的汇总结果每次库存变动时在同一个事务里更新它。第三统计报表。出库趋势、采购成本、销售毛利这些统计靠RuoYi的代码生成器是变不出来的要自己写SQL聚合查询。常见做法是在ruoyi-erp里单独建一个报表包Controller里用startPage()支持分页SQL里用GROUP BY按时间聚合再用SUM()算出金额汇总。这里有个很重要的设计原则报表查的是业务流水表的汇总不是查库存余额表否则时间维度一多就对不上账。2.4 技术选型上的一点提醒单体优先别急着上微服务如果你接手的是一套KS进销存设计源码大概率是单体应用。对于中小规模业务的进销存单体加Redis缓存加MySQL分区表完全够用采购单日单量在一万单以内单体应用的性能瓶颈根本不在应用层而在SQL写法上。做二次开发时我一般建议保持RuoYi原有的Shiro权限体系不要为了“技术新颖”换掉Spring Security或者引入微服务网关。进销存的核心竞争力在于单据流程的正确性和数据一致性而不是架构的复杂度。业务量真的上来后先把报表查询从主库拆到只读库再做订单模块的独立部署这条路比一开始就搞服务拆分要稳得多。3. 进销存表结构设计从商品档案到库存流水先把这六组表画出来3.1 四张基础档案表商品、供应商、客户、仓库进销存的根基是四张基础档案表设计它们时就得看长远因为后面所有单据都引用它们的主键。商品表erp_product至少要有product_code商品编码、product_name商品名称、category_id分类ID、unit计量单位、purchase_price采购价、sale_price销售价、prime_price成本价、status启用状态。供应商表erp_supplier和客户表erp_customer结构很相似但一定不要合并成一张“往来单位表”因为采购和销售的统计口径不同后面做应付应收对账时分开更清楚。仓库表erp_warehouse要设计成支持多仓warehouse_code仓库编码、warehouse_name仓库名称、address地址、manager负责人。这里一个容易犯的错是商品表里放了“当前库存”字段。我见过有人这样做界面查询是方便了但每次出入库都要先查再改这个字段并发高时数据铁定对不上。商品表只放档案信息库存永远放在库存表里。建表语句片段如下CREATE TABLE erp_product ( id bigint NOT NULL AUTO_INCREMENT COMMENT 商品ID, product_code varchar(64) NOT NULL COMMENT 商品编码, product_name varchar(255) NOT NULL COMMENT 商品名称, category_id bigint DEFAULT NULL COMMENT 商品分类ID, unit varchar(20) DEFAULT 件 COMMENT 计量单位, purchase_price decimal(18,2) DEFAULT NULL COMMENT 采购价格, sale_price decimal(18,2) DEFAULT NULL COMMENT 销售价格, prime_cost decimal(18,2) DEFAULT NULL COMMENT 成本价, status char(1) DEFAULT 0 COMMENT 状态0正常 1停用, create_by varchar(64) DEFAULT NULL, create_time datetime DEFAULT NULL, update_by varchar(64) DEFAULT NULL, update_time datetime DEFAULT NULL, remark varchar(500) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_product_code (product_code) ) ENGINEInnoDB COMMENT商品档案表;这段建表逻辑里的几个关键点金额字段全部用decimal(18,2)而不用float/double这是为了避免浮点计算误差导致对账不平product_code加了唯一索引防止同一编码录两遍每个业务表都加了create_by/create_time这类审计字段配合RuoYi的BaseEntity类可以直接复用创建人和创建时间的自动填充逻辑。3.2 单据主表和明细表一单多头永远成对出现采购单、销售单这类交易单据在设计上必然拆成主表明细表两张。主表存单据编号、往来单位ID、仓库ID、单据日期、整单金额、审核状态明细表存商品ID、数量、单价、行金额、折扣。这两张表通过bill_id主外键关联。为什么必须拆因为一张采购单可能包含几十种商品如果所有商品和单据头全塞一张表里重复信息冗余不说修改其中一行商品的单价会波及整张单数据维护非常痛苦。采购单主表和明细表的建表骨架CREATE TABLE erp_purchase_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 采购单号, supplier_id bigint DEFAULT NULL COMMENT 供应商ID, warehouse_id bigint DEFAULT NULL COMMENT 入库仓库ID, order_date datetime DEFAULT NULL COMMENT 单据日期, total_amount decimal(18,2) DEFAULT NULL COMMENT 整单金额, status char(1) DEFAULT 0 COMMENT 状态0草稿 1已审核 2已入库 3已关闭, audit_by varchar(64) DEFAULT NULL, audit_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB COMMENT采购订单主表; CREATE TABLE erp_purchase_order_item ( id bigint NOT NULL AUTO_INCREMENT, bill_id bigint NOT NULL COMMENT 主表ID, product_id bigint DEFAULT NULL COMMENT 商品ID, quantity decimal(18,2) DEFAULT NULL COMMENT 采购数量, price decimal(18,2) DEFAULT NULL COMMENT 采购单价, amount decimal(18,2) DEFAULT NULL COMMENT 行金额, PRIMARY KEY (id), KEY idx_bill_id (bill_id) ) ENGINEInnoDB COMMENT采购订单明细表;这个设计里两个容易被忽略的点第一order_date是业务日期和create_time是两个概念做月报统计时以order_date为准不要以create_time为准。第二明细表的数量为什么要用decimal而不是int进销存里很多商品按重量、长度、面积计件比如钢材按吨、布匹按米数量永远存在小数点所以这里千万别图省事用int后期改表结构比现在用decimal痛苦得多。销售单的结构和采购单几乎一样把supplier_id换成customer_id状态改为草稿、已审核、已出库、已关闭即可。3.3 库存流水表与库存余额表记账录和快照的分工不能混这两个表是整个进销存系统数据一致性的命门。库存流水表记录每一次数量变动哪张单据、什么时间、哪个仓库、商品进还是出、变动前数量、变动后数量。它是只增不改的任何审核、反审核操作都只是在这个表里追加记录绝不更新历史行。库存余额表记录当前各仓库各商品的实时数量它的作用只有一个——让界面查询库存时不用去SUM整张流水表查得快。库存流水表的建表语句CREATE TABLE erp_stock_record ( id bigint NOT NULL AUTO_INCREMENT, warehouse_id bigint DEFAULT NULL COMMENT 仓库ID, product_id bigint DEFAULT NULL COMMENT 商品ID, bill_type char(1) DEFAULT NULL COMMENT 单据类型1采购入库 2销售出库 3盘点 4调拨, bill_no varchar(64) DEFAULT NULL COMMENT 关联单据号, change_qty decimal(18,2) DEFAULT NULL COMMENT 变动数量正数入库 负数出库, before_qty decimal(18,2) DEFAULT NULL COMMENT 变动前库存, after_qty decimal(18,2) DEFAULT NULL COMMENT 变动后库存, remark varchar(255) DEFAULT NULL, create_by varchar(64) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_query (warehouse_id, product_id, create_time) ) ENGINEInnoDB COMMENT库存流水表;为什么流水表一定要记录变动前和变动后的数量有一次我排查一个库存对不上的问题发现某商品的余额是120但把所有流水数量加起来却是150。就是因为早期设计时流水表没记before_qty和after_qty业务员在审核时改了数量程序直接改了余额表流水表没有同步追加记录。后来的排查只能靠猜。如果你在建表时就强制每次变动把前后数量都记下来任何一条业务的变动轨迹都一目了然——这张表的每一行就是一笔库存变动的完整快照对账时按时间线重放就知道了哪一步出了问题。3.4 期初建账系统上线前必须解决的初始库存问题系统上线时仓库里往往已经有存量商品。这些存量怎么录入有两条路一是做一个“期初库存导入”功能手动导入期初数量二是生成一张“盘点入库单”把期初数当成一次入库业务。我更推荐后者因为期初库存本质上也是一次库存变动用盘点单录入会自然生成库存流水后续所有报表统计的口径就统一了。否则期初数只在余额表里流水表里没有对应的记录第一个月对账就发现期初数在报表里对不上。设计期初盘点单时把单据类型设置为“盘点”审核通过后调用库存变更服务同时写流水表和余额表和普通采购入库的入库逻辑复用同一套代码。4. 用RuoYi代码生成器搭完骨架后手写这三块核心业务代码4.1 采购入库审核一个事务里完成状态更新、库存流水、余额变更采购单审核入库是整个系统里事务性最强的操作。它要在一个事务里完成四件事把采购单状态从已审核更新为已入库往流水表里插入入库记录累加库存余额表的数量把操作日志记下来。四件事里任何一件失败整个事务必须回滚。很多新手最容易犯的错是只更新了采购单状态和库存余额漏了流水表或者先写余额后写流水顺序反了导致中间状态被别的线程读到。采购入库的Service层核心代码Override Transactional(rollbackFor Exception.class) public void auditPurchaseOrder(Long orderId) { // 1. 查询采购单主表和明细校验状态必须为已审核且未入库 PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (order null || !1.equals(order.getStatus())) { throw new RuntimeException(采购单不存在或状态不允许入库); } ListPurchaseOrderItem items purchaseOrderItemMapper.selectByBillId(orderId); if (items null || items.isEmpty()) { throw new RuntimeException(采购单没有商品明细无法入库); } // 2. 遍历明细逐行写入库存流水并累加余额 for (PurchaseOrderItem item : items) { // 查询当前库存余额用于记录变动前数量 StockBalance balance stockBalanceMapper.selectForUpdate( order.getWarehouseId(), item.getProductId()); BigDecimal beforeQty (balance null) ? BigDecimal.ZERO : balance.getQuantity(); BigDecimal afterQty beforeQty.add(item.getQuantity()); // 写库存流水表 StockRecord record new StockRecord(); record.setWarehouseId(order.getWarehouseId()); record.setProductId(item.getProductId()); record.setBillType(1); // 采购入库 record.setBillNo(order.getOrderNo()); record.setChangeQty(item.getQuantity()); record.setBeforeQty(beforeQty); record.setAfterQty(afterQty); stockRecordMapper.insert(record); // 更新或插入库存余额表 if (balance null) { balance new StockBalance(); balance.setWarehouseId(order.getWarehouseId()); balance.setProductId(item.getProductId()); balance.setQuantity(afterQty); stockBalanceMapper.insert(balance); } else { balance.setQuantity(afterQty); stockBalanceMapper.updateById(balance); } } // 3. 更新采购单状态为已入库 order.setStatus(2); purchaseOrderMapper.updateById(order); }这段代码里有三个工程上很重要的细节。第一Transactional(rollbackFor Exception.class)必须显式声明否则遇到RuntimeException之外的异常不会回滚。第二stockBalanceMapper.selectForUpdate是查询时带FOR UPDATE锁行防止两个线程同时对同一个商品的库存做加减时出现超卖或负数这个在并发出库场景下非常关键。第三每次入库都在一个事务里同时写流水和余额before_qty和after_qty保证了对账时可以回放整个库存变动轨迹。实际落地时我会在事务提交后再记录操作日志但为了简化演示上面代码省略了日志这部分调用。4.2 销售出库与库存扣减防负库存的兜底校验销售出库的逻辑和采购入库相反从库存余额表里扣减数量。核心风险在于并发场景下单品出库量可能超过当前库存导致余额变成负数。虽然通过FOR UPDATE锁行能保证同一商品的扣减串行执行但业务逻辑上仍然要加一层“扣减前校验”防止业务员明知库存不够仍然强行出库。销售出库扣减库存的校验逻辑// 在循环处理销售明细时对每一行做防负校验 StockBalance balance stockBalanceMapper.selectForUpdate( order.getWarehouseId(), item.getProductId()); if (balance null || balance.getQuantity().compareTo(item.getQuantity()) 0) { throw new RuntimeException(商品【 item.getProductName() 】库存不足当前库存 (balance null ? 0 : balance.getQuantity())); } BigDecimal beforeQty balance.getQuantity(); BigDecimal afterQty beforeQty.subtract(item.getQuantity()); // 写流水负数量并更新余额 StockRecord record new StockRecord(); record.setChangeQty(item.getQuantity().negate()); // 出库记负数 record.setBeforeQty(beforeQty); record.setAfterQty(afterQty); stockRecordMapper.insert(record); balance.setQuantity(afterQty); stockBalanceMapper.updateById(balance);这里容易忽略的一点是库存不足的校验必须放在selectForUpdate之后不能先查一次数量校验、再锁行更新。因为先查后锁存在竞态窗口——两个线程同时查到一个相同的充足库存然后先后执行扣减就可能把库存扣成负数。先锁行再校验天然串行化了同一个商品的库存操作。我在批量出库和多位操作员同时开单的场景里用这套逻辑跑了大半年没有出现过一笔负库存。库存不足时直接抛出运行时异常事务整体回滚界面层捕获后给操作员弹出一个明确提示。4.3 库存查询与低库存预警一条SQL解决效率问题库存查询页要展示商品编码、名称、分类、仓库、当前库存、预警状态。如果用Java程序逐个去查会很慢直接让MySQL用LEFT JOIN一次把多表查出来配合RuoYi自带的分页工具类就行。低库存预警的阈值放在RuoYi参数配置表里查询时实时读取这样改阈值不用改代码。库存列表查询的自定义Mapper语句select idselectStockList parameterTypeStockQuery resultTypeStockVO SELECT p.product_code, p.product_name, p.unit, w.warehouse_name, b.quantity AS stock_qty, CASE WHEN b.quantity IS NULL OR b.quantity lt; #{warningQty} THEN 预警 ELSE 正常 END AS stock_status FROM erp_product p LEFT JOIN erp_stock_balance b ON b.product_id p.id AND b.warehouse_id #{warehouseId} LEFT JOIN erp_warehouse w ON w.id #{warehouseId} where if testproductCode ! null and productCode ! AND p.product_code LIKE CONCAT(%, #{productCode}, %) /if if testproductName ! null and productName ! AND p.product_name LIKE CONCAT(%, #{productName}, %) /if /where ORDER BY p.product_code /select这里的LEFT JOIN有个细节必须把erp_stock_balance的关联条件同时包含warehouse_id否则同一商品在不同仓库有多条余额记录时会查出一堆重复行。如果只需要看单一仓库的库存直接在Mapper里传warehouseId并带上这个条件就对了。预警阈值warningQty通过#{warningQty}传入调用方从RuoYi的ConfigUtils读取比如预警阈值是10库存为0的商品也会被标记为预警正好覆盖断货场景。5. 二次开发的4个高频坑事务边界、金额精度、流水缺失、权限越权5.1 一个事务里只写了主表明细和流水没落库现象采购单在页面显示“已审核”但库存没有增加打开数据库发现erp_purchase_order状态是“2”但erp_stock_record表里根本查不到对应记录。原因Service实现类里对auditPurchaseOrder方法加了Transactional但方法内部调用的是本类中的私有方法或非public方法事务没有进入代理。或者事务只贴在了Controller层的某个方法上Service层根本没加。RuoYi的工程已经扫描了Service包但Spring的事务代理只对通过代理对象调用的public方法生效。解决事务注解统一放在Service层的public方法上Controller层只做参数接收和返回封装不在Controller上做任何涉及多表写入的操作。Service内部的所有流程都放在一个方法里避免同类内部调用绕过代理。如果确实需要拆分方法把被调用方法放到另一个Service类中通过Spring注入调用保证两个方法都在同一个事务上下文里。5.2 金额字段从Double来对账怎么都对不上现象采购单整单金额是1000.20元但多个明细行金额加起来是1000.19元相差一分钱。数据库字段明明是decimal(18,2)为什么还会出现这种问题原因前端传参或Java实体里的金额属性用了Double或Float类型浮点数在二进制里无法精确表示十进制小数导致出现0.999999之类的尾差。常见做法是页面Vue里把金额转成了Number类型后台直接接收成了Double再转BigDecimal时调用了new BigDecimal(double)构造函数。解决前端金额用字符串传给后端Java实体类金额字段全部声明为BigDecimal数据库字段用decimal(18,2)。已有的Double类型字段在接收时用BigDecimal.valueOf(value)转换不要用new BigDecimal(value)。代码规范上加一条Controller接收参数用RequestBody里的DTODTO内金额字段类型是BigDecimal不让字符串或Double有机会混进来。这个坑在报表毛利统计时暴露得最快差一分钱在汇总里被放大成几百块的差异。5.3 库存余额在界面上改没了流水表里却找不到这笔变动现象某商品库存从80突然变成了65业务员说没有做过任何出库操作流水表里也查不到数量为-15的记录。原因有人直接对erp_stock_balance表做了UPDATE操作可能是为了临时修数据也可能是在某个“库存调整”功能中直接改的余额没有调用写流水的Service。库存余额表成了“黑匣子”谁改过、为什么改、什么时候改的完全不可追溯。解决任何库存变动都必须走统一的库存变更Service这个Service里先写流水表、再更新余额表。开发阶段就在数据库层面禁止对erp_stock_balance表做手动的UPDATE/DELETE操作DBA把表的更新权限收掉只允许通过应用账号执行。如果确实需要人工修正余额也必须生成一张“库存调整单”说明调整原因审核后走代码逻辑而不是直接动数据库。给erp_stock_record表加一个create_time索引定期抽查最近一周的变动记录对不上时把问题消灭在早期。5.4 RuoYi自带的权限只到菜单级商品明细级没有数据隔离现象销售部的A操作员登录后能看到采购部的进货成本某业务员只负责华东区的客户却可以查到华南区客户的订单明细。RuoYi的菜单权限配置正常但数据没有任何隔离。原因RuoYi默认的权限模型是RBAC控制的是菜单和按钮到了数据行级比如只看本部门数据、只看自己创建的单据需要自己做数据范围控制。进销存里的采购成本、客户信息都属于敏感数据不隔离迟早出问题。解决在业务查询Mapper的SQL里统一拼接数据权限条件。我在erp_purchase_order这类业务表上加了create_dept_id字段在Service层调用查询前从SecurityUtils拿到当前登录用户的部门ID把“只能查本部门的数据”条件拼进SQL。做法是定义一个数据权限工具类在Mapper的XML里用${params.dataScope}拼条件注意这里用${}而不是#{}因为它是权限规则拼接不是参数值绑定。给部门管理员开放一个“可查看下级部门数据”的开关让数据范围支持“本人/本部门/本部门及以下/全部”四级配合系统模块里的部门管理灵活度和安全性能兼顾。6. 进阶技巧用库存对账脚本和每日定时任务把历史数据从“说不出话”变成“可追溯”系统跑一段时间后库存对账是对技术方案最严苛的验收。每个月月底财务要求业务员签字的库存盘点表和你系统里查询出的库存余额如果差一分钱业务员不会认为是自己的Excel记错了只会认为“系统有bug”。所以我在物料盘点前必做的一个动作用途很直接写一个对账SQL按商品和仓库维度把流水表所有记录按时间重放一遍算出理论库存再和余额表对比。这一步不是给财务看的而是给自己排查数据的一致性做底牌。-- 按商品仓库核对流水重算库存与余额表是否一致 SELECT w.id AS warehouse_id, w.warehouse_name, p.id AS product_id, p.product_code, COALESCE(SUM(r.change_qty), 0) AS sum_qty, COALESCE(b.quantity, 0) AS balance_qty, (COALESCE(SUM(r.change_qty), 0) - COALESCE(b.quantity, 0)) AS diff_qty FROM erp_warehouse w CROSS JOIN erp_product p LEFT JOIN erp_stock_record r ON r.warehouse_id w.id AND r.product_id p.id LEFT JOIN erp_stock_balance b ON b.warehouse_id w.id AND b.product_id p.id GROUP BY w.id, w.warehouse_name, p.id, p.product_code, b.quantity HAVING diff_qty ! 0;如果查出diff_qty不为0的行说明问题要么是余额表被绕过流水表改过要么是某张单据审核时流水和余额没有在同一个事务里提交。找到目标商品后再按时间顺序查流水表就能锁定具体是哪一笔变动造成的。RuoYi自带定时任务可以配合做每日对账。我通常配一个每天凌晨1点跑的Quartz任务Java代码里执行上述SQL把差异行写入一张erp_stock_check_log异常表并在系统首页给管理员一个红点提醒。这个设计的好处是异常在第二天一早就能暴露出来而不是拖到月底盘点时对不上账。我习惯把定时任务里加一个限制只检查昨天发生过库存变动的商品避免每天全量扫描。一次完整排查的经验告诉我库存对账异常发现得越早原因越好定位等到财务月结那天再报错就真的是灾难了。这套手段配合第2章到第5章的工程设计和避坑方案正常业务量下能保证月月对账平得上。希望帮到你。本文还有配套的精品资源点击获取