ERP进销存系统源码实战:MySQL表设计、库存流转与部署避坑 简介这是一套适用于中小企业业务流程信息化建设的开源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_pricecategory商品分类name, parent_id, sortwarehouse仓库档案name, address, manager, statussupplier供应商档案name, contact, phone, statuscustomer客户档案name, contact, phone, credit_limitpurchase_order采购单主表order_no, supplier_id, warehouse_id, total_amount, statuspurchase_item采购单明细order_id, product_id, quantity, price, amountsale_order销售单主表order_no, customer_id, warehouse_id, total_amount, statussale_item销售单明细order_id, product_id, quantity, price, amountstock实时库存product_id, warehouse_id, quantity, lock_quantitystock_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 - 下单数量。好处是实现简单一个事务里搞定坏处是并发高的时候容易超卖。两个订单同时读到 quantity10各自扣掉 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 影响行数为 0Service 层据此抛“库存不足”。这个判断放在 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. 查询明细行逐行增加库存并写流水 ListPurchaseItem 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_type2 的流水。发货之后如果客户退货再走 flow_type3 的退货入库流水。一条业务闭环上的每一个库存变动点都有对应流水后面查账才查得清。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 PageResultSaleOrderVO querySaleOrderPage(SaleOrderQuery query, User currentUser) { // 非管理员追加数据权限条件 if (!isAdmin(currentUser)) { query.setCreateBy(currentUser.getId()); } ListSaleOrderVO 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 4quantity 为正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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: your_password jackson: time-zone: GMT8时区这个配置看着不起眼漏掉的话后面所有时间字段都会差八小时具体现象和排查方法见下一节。第三步启动后端第四步启动前端第五步用 admin 账号登录。前端默认监听 8080 端口后端 8081如果端口冲突在 vue.config.js 的 devServer.proxy 里一并把 target 改成后端实际端口。提示环境搞乱了恢复最快的方式是重新执行上面三条 MySQL 命令初始化数据会覆盖掉所有改动。开发阶段别心疼数据等验证完功能再往里灌真实数据。5.2 五个高频踩坑记录现象、原因、解决第一条库存出现负数。现象库存充足的情况下销售单提交后stock 表 quantity 变成负数。原因预占库存的 SQL 里少了 quantity - lock_quantity 本次数量 这个条件或者 Service 层先 SELECT 后判断两个并发请求读到同一个旧值。解决把判断下推到 UPDATE 的 WHERE 条件里用受影响行数判断是否充足再不行就引入乐观锁版本号。第二条所有时间字段差八小时。现象前端列表页显示的时间比数据库实际时间早八小时。原因JDBC 连接串没配 serverTimezonejackson 序列化时又按 UTC 处理。解决连接串加 serverTimezoneAsia/Shanghaiapplication.yml 里 jackson.time-zone 配 GMT8。改完重启后端并清一下浏览器缓存。第三条单据被重复审核库存重复增加。现象同一张采购单连续点两次审核第二次库存又加了一遍。原因审核接口只校验了单据存在没在状态字段上做条件更新。解决更新语句带状态条件 UPDATE purchase_order SET status AUDITED WHERE id ? AND status PENDING受影响行数为 0 说明已经被处理过直接抛异常。第四条前端金额显示 100.1 变成 100.09999999999999。现象JSON 返回的金额字段变成了一长串小数。原因后端用 Double 接收或返回金额。解决数据库统一 decimal(12,2)Java 端用 BigDecimalController 返回的 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_type1 的记录。第三组做一笔销售出库数量 30确认库存变成 70预占和扣减都正常释放。第四组把刚才的销售单做退货数量 10确认库存从 70 变成 80。第五组发起一次盘点实盘数填 80确认无差异然后手动改动库存制造差异盘盈 5 个确认流水里有 flow_type4 的记录且 before 和 after 两个字段都对得上。6.2 对账单把流水和库存表拉平五组数据走完后执行一次 5.3 节的对账 SQL如果 account_quantity 和 stock_quantity 一致说明这套源码的主链路是可信的。不一致的话优先查第二、三、四步的流水有没有漏写不要先怀疑库存扣减逻辑——绝大多数情况是某个分支没有走统一的流水写入方法。这套流程跑完基本可以判断一套进销存源码能不能用于交付。从那以后我拿到任何一套进销存源码第一件事不是看页面好不好看而是先做库存对账把流水的汇总和库存表拉平拉不平的直接放弃省下的时间远大于下载时节省的那几分钟。希望帮到你。本文还有配套的精品资源点击获取