简介:这是一套适用于中小企业业务流程信息化建设的开源ERP进销存系统源码包,整合采购、销售、库存与财务模块,并基于MySQL提供数据支撑。系统采用Web前后端交互架构,源码可自由二次开发,适合Java、JSP方向学习者深入理解进销存业务逻辑、角色权限、报表分析等关键设计。整个压缩包约14.26MB,共1448个文件,以Java与JSP后端逻辑为主,辅以HTML、CSS、JavaScript及EasyUI构建前端界面,并搭配PNG、GIF图标素材和数据库初始化SQL脚本,便于直接部署还原运行环境。目前已有769人学习下载。通过研读源码,可掌握采购入库、销售出库、库存台账更新等单据流转细节,理解MySQL表结构设计与常见业务报表的统计思路;同时其开源模块化结构也便于二次扩展或与CRM等系统对接,是从校园到企业级开发过程中很有价值的参考范例。
1. ERP进销存系统源码:下载容易,跑通和改造是两回事
做开发的这几年,我下载过的 ERP 进销存系统源码少说也有五六份。大部分有一个共性:压缩包解开很快,MySQL 脚本导入也没报错,但真正跑起来后要么库存对不上,要么采购单保存失败,最后只能当学习资料吃灰。这套源码不一样的地方在于,它的表结构和业务代码是能对上号的——采购入库、销售出库、退货、盘点四条主链路由头到尾贯穿,数据库里每一张业务表都有对应的 Mapper 和 Service 方法。对正在做课程设计、接小型外包,或者帮工厂做信息化改造的开发者来说,它更像一个能直接落地的底子,而不是一篇需要二次翻译的文档。
2. MySQL 表设计先行:十一张核心业务表与库存流转逻辑
2.1 表结构拆解:从商品档案到库存流水
判断一套进销存源码能否用,最快的办法是看它的表设计。这套源码一共十六张表,其中十一张是核心业务表,另外五张是用户、角色、菜单和两张关联表,走的是经典 RBAC 权限模型。核心业务表清单如下,关键字一眼就能看懂用途。
| 表名 | 用途 | 关键字段 |
|---|---|---|
| product | 商品档案 | sku_code, name, category_id, spec, unit, purchase_price, sale_price |
| category | 商品分类 | name, parent_id, sort |
| warehouse | 仓库档案 | name, address, manager, status |
| supplier | 供应商档案 | name, contact, phone, status |
| customer | 客户档案 | name, contact, phone, credit_limit |
| purchase_order | 采购单主表 | order_no, supplier_id, warehouse_id, total_amount, status |
| purchase_item | 采购单明细 | order_id, product_id, quantity, price, amount |
| sale_order | 销售单主表 | order_no, customer_id, warehouse_id, total_amount, status |
| sale_item | 销售单明细 | order_id, product_id, quantity, price, amount |
| stock | 实时库存 | product_id, warehouse_id, quantity, lock_quantity |
| stock_flow | 库存流水 | product_id, warehouse_id, flow_type, quantity, before_quantity, after_quantity, ref_no |
先看 product 和 stock 的关系。product 存商品静态信息,比如编码、名称、规格、单位、采购价、销售价;stock 存某个商品在某个仓库里的实时数量,包含 quantity 和 lock_quantity 两个字段。商品和库存拆成两张表,意味着同一个商品可以分布在多个仓库,数据不冗余,查某个仓的库存时只需 join 这两个字段。
另一个值得注意的设计是 purchase_order 和 purchase_item 的主从结构。主表存单据头信息,比如供应商、仓库、总金额、审核状态;明细表存每一行商品的编码、数量、单价。进销存里几乎所有单据——采购、销售、退货、盘点——都采取这种主从结构,原因很简单:主表负责单据生命周期,明细表负责商品行明细,两张表通过 order_id 关联,查询时 join 一次就能拿到完整单据。还有一点容易被忽略,明细表里除了外键 product_id,一般还会冗余一份 sku_code 和 product_name。这样设计很务实:商品档案允许修改,但历史单据上的商品名称和编码必须保持下单那一刻的快照。如果只存 product_id,后续把商品名称改掉,历史报表也会跟着变,对账的时候根本说不清。
2.2 库存扣减的两种方案:实时扣减与预占扣减
库存扣减是进销存里最容易出事故的环节。常见的做法有两种。
方案 A 是实时扣减:销售单创建成功的同时直接 UPDATE stock SET quantity = quantity - 下单数量。好处是实现简单,一个事务里搞定;坏处是并发高的时候容易超卖。两个订单同时读到 quantity=10,各自扣掉 8,最后库存变 -6,这种事在真实业务里经常发生。
方案 B 是预占扣减:下单时不扣 quantity,而是增加 lock_quantity;等订单审核通过、实际出库时,再把 quantity 减掉、lock_quantity 释放。这套源码用的是方案 B,从 stock 表的字段设计就能看出来。预占的好处是订单创建阶段就把货“锁住”,后面审核不通过时释放锁,不会出现一单多卖。对应 SQL 如下。
-- 下单预占库存(方案B 第一步) UPDATE stock SET lock_quantity = lock_quantity + #{quantity} WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND quantity - lock_quantity >= #{quantity};这条 SQL 的要点在 WHERE 条件的最后一行:quantity - lock_quantity 表示当前可售库存,必须大于等于本次要预占的数量,条件不满足时 UPDATE 影响行数为 0,Service 层据此抛“库存不足”。这个判断放在 SQL 里而不是先 SELECT 再判断,是为了避免并发下两个请求同时读到同一个可用库存值。参数 productId 和 warehouseId 定位到唯一一条库存记录,quantity 是本次预占数,三者缺一不可。
-- 出库扣减(方案B 第二步) UPDATE stock SET quantity = quantity - #{quantity}, lock_quantity = lock_quantity - #{quantity} WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND lock_quantity >= #{quantity};出库扣减的判断条件是 lock_quantity >= 本次出库数量,防止把没有预占过的库存扣掉。两步操作分别对应销售订单的“提交”和“发货”两个动作,中间隔着审核环节,所以能拆分出两个事务。
2.3 库存流水表:所有库存变动都要留下痕迹
stock 表只有当前数量,没有历史。要回答“这个月入库多少、出库多少、为什么账面数和实物对不上”这类问题,必须有流水表。stock_flow 的字段设计是围绕审计需求来的:flow_type 区分 1 采购入库、2 销售出库、3 退货入库、4 盘点调整、5 期初,quantity 存放变动数量(正负),before_quantity 和 after_quantity 存放变动前后的库存值,ref_no 关联对应的业务单号。
-- 查询某商品在某仓库的流水 SELECT flow_type, quantity, before_quantity, after_quantity, ref_no, create_time FROM stock_flow WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} ORDER BY create_time ASC;每个库存变动点都要调用统一的写入方法,把 before_quantity 作为变动前的当前库存传入,写入后再用 after_quantity = before_quantity + quantity 做校验。这个约定在代码里用事务约束住之后,后面做对账就非常快——只要把流水表按商品汇总,和 stock 表当前值比对,不相等就是有环节漏写了流水。
我把 before_quantity 和 after_quantity 同时存下来,是因为如果只存变动数量,一旦某条流水被误删或改错,后续数据全部无法恢复。有了前后值,可以通过相邻两条流水的 after 等于下一条的 before 这条约束,辅助定位是哪一笔出了问题。这套源码能直接用起来,很大程度就是因为这些细节没省。
3. 核心流程代码:采购入库、销售出库与报表统计的实现细节
3.1 代码分层:Controller 管参数,Service 管事务,Mapper 管 SQL
拿到源码第一件事,先看包的目录结构。这套代码是标准的 Spring Boot 分层:controller 包下是接口入口,service 包下是业务逻辑,mapper 包下是数据库操作。进销存这种强事务场景,最忌讳的就是在 Controller 里写业务逻辑——多个方法调用之间的事务边界没法控制。
一个可以量化的检查点:Service 层所有写操作的方法上是否加了 @Transactional 注解。这套源码在采购单审核、销售单提交、盘点确认这几个核心方法上都加了,说明事务边界是刻意设计过的。后面写二次开发代码时,也要保持同样的习惯:一个业务操作对应一个事务方法,不要在 Controller 层拼装多个 Service 调用。
3.2 采购入库:从单据审核到库存增加
采购模块的业务链路是:创建采购单(状态 PENDING)→ 审核通过(状态 AUDITED)→ 到货入库(状态 FINISHED)。库存真正发生变化是在最后一步,之前两步只操作单据和明细表。
@Transactional(rollbackFor = Exception.class) public void finishPurchaseOrder(Long orderId, Long operatorId) { // 1. 校验单据状态,防止重复入库 PurchaseOrder order = purchaseOrderMapper.selectById(orderId); if (order == null || !"AUDITED".equals(order.getStatus())) { throw new BusinessException("采购单不存在或已不是待入库状态"); } // 2. 查询明细行,逐行增加库存并写流水 List<PurchaseItem> items = purchaseItemMapper.selectByOrderId(orderId); for (PurchaseItem item : items) { Stock stock = stockMapper.selectByProductAndWarehouse( item.getProductId(), order.getWarehouseId()); int beforeQty = stock == null ? 0 : stock.getQuantity(); // 库存表没有该商品记录时先插入 if (stock == null) { stockMapper.insert(item.getProductId(), order.getWarehouseId(), item.getQuantity()); } else { stockMapper.increaseQuantity( item.getProductId(), order.getWarehouseId(), item.getQuantity()); } // 写流水,before/after 保持可追溯 stockFlowMapper.insert( item.getProductId(), order.getWarehouseId(), 1, item.getQuantity(), beforeQty, beforeQty + item.getQuantity(), order.getOrderNo()); } // 3. 更新单据状态,置为已入库 order.setStatus("FINISHED"); purchaseOrderMapper.updateById(order); }这份代码的逻辑不复杂,但有两个细节值得留意。第一,库存表没有商品记录时先 insert 再 update,避免空指针;第二,beforeQty 是在循环内查询的,假设同一张采购单里没有重复商品行,这个值是准确的——如果出现重复商品行,后面一行会拿到前面一行更新后的值,流水就会多记一笔。生产环境里我会在创建采购单时按商品维度做唯一约束,或者先聚合明细再入库。
方法参数 orderId 是采购单主键,operatorId 是当前操作人,代表“谁做了这次入库”。在完整源码里 operatorId 还会写入流水表的 operate_by 字段。真实项目里这个字段不能省,出了库存差异要追责时,它就是唯一的线索。
3.3 销售出库:库存校验、预占、扣减三步走
销售链路比采购多一道库存判断。提交订单时预占库存,审核通过后扣减库存,订单取消时释放预占。核心是提交订单这个动作,代码如下。
@Transactional(rollbackFor = Exception.class) public void submitSaleOrder(SaleOrderDTO dto, Long operatorId) { // 1. 创建销售单主表,状态为 PENDING SaleOrder order = buildOrder(dto, operatorId); saleOrderMapper.insert(order); // 2. 逐行校验并预占库存 int totalAmount = 0; for (SaleItemDTO item : dto.getItems()) { totalAmount += item.getPrice() * item.getQuantity(); int affected = stockMapper.lockStock( item.getProductId(), dto.getWarehouseId(), item.getQuantity()); if (affected == 0) { // 预占失败,回滚整个事务 throw new BusinessException("商品 " + item.getSkuCode() + " 库存不足"); } } // 3. 回写总金额 order.setTotalAmount(totalAmount); saleOrderMapper.updateById(order); }lockStock 方法对应 2.2 节那条 UPDATE SQL。重点说一下回滚机制:for 循环里任何一行预占失败,异常抛出后整个事务回滚,前面几行已经完成的 lock_quantity 增加会被一并撤销。这是进销存系统里最核心的一条纪律——要么全部单据和库存都成功,要么都不成功,不允许出现半张销售单占着库存的情况。
预占成功后,订单状态还是 PENDING,库存数量没变,只是可用量少了。真正出库发货时调用 deliverSaleOrder,执行 quantity 和 lock_quantity 同时减少的 UPDATE,同时写一条 flow_type=2 的流水。发货之后如果客户退货,再走 flow_type=3 的退货入库流水。一条业务闭环上的每一个库存变动点都有对应流水,后面查账才查得清。
3.4 经营日报:一张 SQL 完成当日汇总
报表模块是进销存最容易被人忽视、也是客户验收时最看重的一块。这套源码里经营日报的实现很简单——基于 sale_order 和 sale_item 做聚合查询。
-- 当日销售汇总(金额单位:分) SELECT DATE(create_time) AS biz_date, COUNT(DISTINCT so.id) AS order_count, SUM(si.quantity) AS product_count, SUM(si.price * si.quantity) AS sale_amount FROM sale_order so JOIN sale_item si ON si.order_id = so.id WHERE so.status IN ('FINISHED', 'DELIVERED') AND create_time >= #{startTime} AND create_time < #{endTime} GROUP BY DATE(create_time) ORDER BY biz_date DESC;这张 SQL 的要点有两个。一是状态过滤:只统计已经发货和完成的订单,PENDING 和已取消的不算,避免把刚创建还没审核的单据计入营收。二是时间范围用 >= 开始、< 结束的半开区间,而不是 BETWEEN,因为 create_time 带时分秒,直接 BETWEEN 会把零点整的数据重复算到前两天。
金额字段在数据库里是 decimal(12,2),这里的 SUM 结果是 BigDecimal。JSON 序列化到前端时如果直接转成 Number,大金额会丢精度,后面避坑章节还会再提一次。
4. 权限与多仓库:RBAC 精简模型与数据隔离边界
4.1 权限模型:菜单权限和操作权限要分开设计
这套进销存系统的权限部分没有过度设计,就是经典的五张表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。管理员给角色勾选菜单,用户挂到角色下,登录后根据角色菜单集合渲染左侧导航栏。
但只做菜单级权限是不够的。进销存场景里,最常见的管理诉求不是“能不能看到采购菜单”,而是“能不能审核采购单”。很多源码把“审核”和“录单”揉在同一个菜单权限里,实际操作时会发现业务员能打开审核页面,甚至能点审核按钮——只是没人拦。我拿到源码后第一步会检查它有没有做按钮级权限,一般做法是在菜单表加一个 perms 字段存操作编码,前端按钮上用指令判断当前用户是否拥有该编码,后端在接口上再做一次拦截。这套源码里菜单表正好有这个字段,所以二次开发时不用额外加表。
4.2 数据权限:业务员只能看自己的单据
单据表都带 create_by 字段,这是数据隔离的基础。在列表查询里,需要区分两类角色:普通业务员查询时强制追加 create_by = 当前用户;部门主管或管理员查询时不加这个条件,看到全量数据。
具体实现上,我习惯在 Service 层手动拼条件,而不是在 XML 里写动态 SQL 判断,原因是这样更直观,排查权限问题时肉眼就能看到条件有没有生效。
public PageResult<SaleOrderVO> querySaleOrderPage(SaleOrderQuery query, User currentUser) { // 非管理员追加数据权限条件 if (!isAdmin(currentUser)) { query.setCreateBy(currentUser.getId()); } List<SaleOrderVO> records = saleOrderMapper.selectPage(query); // 按“提交时间倒序,审核时间正序”的排序规则处理 return PageResult.build(records, query.getTotal()); }数据权限一个容易踩的坑:前端分页参数里如果带了 create_by 字段,后端必须用当前登录用户的值覆盖,而不是信任前端传值。否则业务员改一下请求参数就能看到别人的订单,这在交付验收时是致命伤。
仓库维度的隔离比用户维度更复杂。多仓情况下,普通仓管员只能看本仓的单据和库存,接单员可以跨仓下单。实现方式是在 warehouse 表加一个 manager_id 字段,查询库存时 if 当前用户不是管理员,join 仓库表带 manager_id = 当前用户的条件。这套源码里 warehouse 表有 manager 字段,默认就是按这个思路做的。
4.3 盘点差异:盘盈盘亏怎么入账不破坏流水
盘点不是一个简单的“改库存数字”。直接 UPDATE stock 表改数量,会导致库存流水断档,对账时永远对不平。规范流程是:创建盘点单 → 盘点单状态为 COUNTING → 录入实际数量 → 确认盘点 → 对比账面数和实盘数 → 自动生成盘盈/盘亏流水。
盘点单明细表的设计通常是这样的字段:product_id, warehouse_id, book_quantity(账面数), real_quantity(实盘数), diff_quantity(差异数)。确认时,diff_quantity > 0 走盘盈入库,flow_type = 4,quantity 为正;diff_quantity < 0 走盘亏出库,quantity 为负。同时更新 stock 表。
-- 确认盘点时更新库存(盘盈示例) UPDATE stock SET quantity = real_quantity WHERE product_id = #{productId} AND warehouse_id = #{warehouseId};注意这里用的是直接覆盖 quantity 而不是增减,因为盘点确认的是最终实盘数,不是变化量。流水表的 before 和 after 字段正好记录下调整前后的值,审计线索完整。盘点确认这一步必须有事务保护,否则库存改了但盘点单状态没更新,会出现单据已确认、库存已变、流水缺失的三方不一致。
5. 部署与避坑:本机跑通流程及五个数据一致性排查记录
5.1 本地启动:从建库到页面跑通
源码解压后目录结构一般是 backend(后端)和 frontend(前端)两个文件夹。后端是 Spring Boot 项目,前端是 Vue2 + Element UI。本地启动按下面五步走。
第一步,创建数据库并导入初始化脚本。脚本一般在 sql 目录下,按文件名顺序执行。
mysql -uroot -p -e "CREATE DATABASE erp_db DEFAULT CHARACTER SET utf8mb4;" mysql -uroot -p erp_db < /path/to/sql/init.sql mysql -uroot -p erp_db < /path/to/sql/data.sqlinit.sql 建表,data.sql 灌初始数据,这两步分开是为了后续重置环境方便。二次开发时如果改了表结构,自己导出脚本也按这个习惯分成 schema 和 data 两份。
第二步,修改后端配置。application.yml 里的数据源地址、账号密码改成你自己的,同时确认时区参数配好。
spring: datasource: url: jdbc:mysql://localhost:3306/erp_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: your_password jackson: time-zone: GMT+8时区这个配置看着不起眼,漏掉的话后面所有时间字段都会差八小时,具体现象和排查方法见下一节。第三步启动后端,第四步启动前端,第五步用 admin 账号登录。前端默认监听 8080 端口,后端 8081,如果端口冲突,在 vue.config.js 的 devServer.proxy 里一并把 target 改成后端实际端口。
提示:环境搞乱了恢复最快的方式是重新执行上面三条 MySQL 命令,初始化数据会覆盖掉所有改动。开发阶段别心疼数据,等验证完功能再往里灌真实数据。
5.2 五个高频踩坑记录:现象、原因、解决
第一条:库存出现负数。
现象:库存充足的情况下销售单提交后,stock 表 quantity 变成负数。
原因:预占库存的 SQL 里少了 quantity - lock_quantity >= 本次数量 这个条件,或者 Service 层先 SELECT 后判断,两个并发请求读到同一个旧值。
解决:把判断下推到 UPDATE 的 WHERE 条件里,用受影响行数判断是否充足;再不行就引入乐观锁版本号。
第二条:所有时间字段差八小时。
现象:前端列表页显示的时间比数据库实际时间早八小时。
原因:JDBC 连接串没配 serverTimezone,jackson 序列化时又按 UTC 处理。
解决:连接串加 serverTimezone=Asia/Shanghai,application.yml 里 jackson.time-zone 配 GMT+8。改完重启后端并清一下浏览器缓存。
第三条:单据被重复审核,库存重复增加。
现象:同一张采购单连续点两次审核,第二次库存又加了一遍。
原因:审核接口只校验了单据存在,没在状态字段上做条件更新。
解决:更新语句带状态条件 UPDATE purchase_order SET status = 'AUDITED' WHERE id = ? AND status = 'PENDING',受影响行数为 0 说明已经被处理过,直接抛异常。
第四条:前端金额显示 100.1 变成 100.09999999999999。
现象:JSON 返回的金额字段变成了一长串小数。
原因:后端用 Double 接收或返回金额。
解决:数据库统一 decimal(12,2),Java 端用 BigDecimal,Controller 返回的 VO 里金额字段类型必须是 String 或 BigDecimal 且配置 JsonFormat。序列化时大数字转 String,前端再解析。
第五条:盘点确认后库存数与流水对不上。
现象:盘点后查 stock 表没问题,但把 stock_flow 汇总出来和 stock 表一对比,差一笔。
原因:确认盘点的方法里,更新库存和写流水不在同一个事务里,库存更新成功了,流水没写进去。
解决:盘点确认方法整体加 @Transactional(rollbackFor = Exception.class),并且先写流水再更新库存,哪一步失败都回滚。
5.3 兜底的库存对账脚本
上面五条坑解决后,如果还是不放心,可以写一个对账脚本定期跑一遍,把流水表的汇总和 stock 表做对比。
-- 库存对账:流水汇总与实时库存对比 SELECT sf.product_id, sf.warehouse_id, SUM(sf.quantity) AS account_quantity, s.quantity AS stock_quantity FROM stock_flow sf JOIN stock s ON s.product_id = sf.product_id AND s.warehouse_id = sf.warehouse_id GROUP BY sf.product_id, sf.warehouse_id, s.quantity HAVING account_quantity <> stock_quantity;这条 SQL 把 stock_flow 里所有变动数量求和,包括期初、采购、销售、退货、盘点调整,理论上应当等于 stock 表的当前值。如果初始化库存时是直接改 stock 表而没写期初流水,这里会立刻报出大量不一致——这本身就是个信号,说明初始化不规范。跑出差异不要慌,按 ref_no 去翻业务单据,通常都能定位到具体是哪个环节漏写了流水。
注意:对账脚本只能发现流水和库存不一致,发现不了“流水写对了但业务单据状态错”的问题。所以还是要在业务代码里先把状态机校验做扎实,脚本只是兜底。
6. 进阶验证:五组测试数据把整套流程压一遍
6.1 五组数据覆盖主要业务路径
拿到源码跑通之后,不能只看着页面觉得没报错就算完。我每拆一套进销存源码,都会准备五组测试数据,按业务顺序过一遍:
第一组:新建分类、商品、供应商、客户、仓库各一个,确认基础档案能正常保存。
第二组:给商品做一笔采购入库,数量 100,确认库存从 0 变成 100,流水表多一条 flow_type=1 的记录。
第三组:做一笔销售出库,数量 30,确认库存变成 70,预占和扣减都正常释放。
第四组:把刚才的销售单做退货,数量 10,确认库存从 70 变成 80。
第五组:发起一次盘点,实盘数填 80,确认无差异;然后手动改动库存制造差异,盘盈 5 个,确认流水里有 flow_type=4 的记录,且 before 和 after 两个字段都对得上。
6.2 对账单:把流水和库存表拉平
五组数据走完后,执行一次 5.3 节的对账 SQL,如果 account_quantity 和 stock_quantity 一致,说明这套源码的主链路是可信的。不一致的话,优先查第二、三、四步的流水有没有漏写,不要先怀疑库存扣减逻辑——绝大多数情况是某个分支没有走统一的流水写入方法。
这套流程跑完,基本可以判断一套进销存源码能不能用于交付。从那以后,我拿到任何一套进销存源码,第一件事不是看页面好不好看,而是先做库存对账:把流水的汇总和库存表拉平,拉不平的直接放弃,省下的时间远大于下载时节省的那几分钟。希望帮到你。
本文还有配套的精品资源,点击获取