2026/10/30 5:58:55

多商户多仓库云进销存源码:扫码出入库与库存防超卖实战

多商户多仓库云进销存源码:扫码出入库与库存防超卖实战 简介MF00730-多商户多仓库带扫描云进销存源码是一套面向中小型企业及集团化运营团队的企业管理解决方案适合需要独立管理多商户、多仓库业务的开发者与运维人员二次开发或直接部署。系统以云端技术为基础覆盖多商户独立账号、多仓库库存实时同步、扫描出入库与盘点、库存预警调拨、采购销售全程跟踪以及销售/库存/采购报表分析等核心模块帮助解决库存积压、数据孤岛与人工录入出错等问题。压缩包共1319个文件以509个php业务代码、206个js脚本、53个css样式与42个html页面为主体另含208个png、68个gif等界面素材及sql、db数据库文件整体约22.05MB目录结构完整便于按模块检索。目前已有96人学习下载可作为进销存系统搭建、功能定制与云端数据同步的参考实现。1. 多商户多仓库带扫描云进销存一套源码到底能省掉多少自研成本做过零售或批发 SaaS 的人都知道进销存系统本身不难难的是把「多商户」「多仓库」「条码扫描」这三件事同时塞进一套数据模型里还不打架。MF00730 这个源码包从标题看就是冲着这个组合去的一套云进销存支持多个商户各自独立经营每个商户下又能挂多个仓库出入库环节用条码扫描枪直接扫。如果你正在接私活、做行业 SaaS 或者给公司内部搭一套轻量 ERP这套东西的价值不在于代码写得多漂亮而在于它把库存流水、商户隔离、仓库调拨这些最容易出 bug 的地方已经跑通了一遍。适合谁适合有 Java 基础、能看懂 Spring Boot 和 MyBatis、想省掉从零设计表结构那两三周的人。不适合谁不适合指望开箱即用、连数据库都不想装的人。2. 多商户多仓库的数据模型先看懂表结构再动手改代码2.1 商户隔离到底靠 tenant_id 还是靠独立库拿到一套多商户源码第一件事不是跑起来而是翻它的表结构。常见做法有两种一种是所有商户共用一套表靠tenant_id字段做逻辑隔离另一种是每个商户一个独立数据库靠动态数据源切换。MF00730 这类云进销存源码大概率走的是前者因为部署成本低、运维简单。你需要在sql目录里找到建表语句重点看这几张表sys_tenant商户表、sys_warehouse仓库表、goods_stock库存表、stock_record出入库流水。判断隔离方式的方法很简单看goods_stock里有没有tenant_id字段。如果有那就是共享表方案。共享表方案的好处是加商户不用改配置坏处是每个查询都必须带tenant_id条件漏一个就是数据泄露。我一般会在 MyBatis 的拦截器里统一注入tenant_id而不是靠每个 Mapper 手写。下面是一个基于 MyBatis Interceptor 的租户条件注入示例// MyBatis 拦截器自动为查询语句追加 tenant_id 条件 Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String originalSql boundSql.getSql(); // 从 ThreadLocal 中取出当前登录用户的商户 ID Long tenantId TenantContext.getTenantId(); if (tenantId ! null !originalSql.contains(tenant_id)) { // 只对包含 stock 或 warehouse 的查询做注入避免误伤系统表 if (originalSql.toLowerCase().contains(goods_stock) || originalSql.toLowerCase().contains(stock_record)) { String newSql originalSql AND tenant_id tenantId; // 通过反射替换 SQL Field field boundSql.getClass().getDeclaredField(sql); field.setAccessible(true); field.set(boundSql, newSql); } } return invocation.proceed(); } }这段代码的逻辑是每次 MyBatis 执行查询前拦截器检查当前线程有没有绑定tenant_id如果有且 SQL 里还没写租户条件就自动追加。参数说明TenantContext是你自己实现的 ThreadLocal 工具类在用户登录后把tenant_id塞进去goods_stock和stock_record是需要隔离的核心表其他表如字典表可以不加。注意这种字符串拼接方式只适合简单查询如果原 SQL 已经带了WHERE子句你得判断是追加AND还是插入到WHERE后面否则会拼出语法错误。更稳妥的做法是用 JSqlParser 解析 SQL 再改写但那个依赖体积不小小项目用字符串处理就够了。2.2 多仓库库存怎么算才不出现负库存多仓库场景下库存表的设计有两种常见思路。第一种是goods_stock表里一行记录对应「商品 仓库」的组合库存数量直接存在这一行里。第二种是只存流水库存通过SUM流水实时算出来。前者查询快但容易和流水对不上后者数据准但性能差。MF00730 这类源码通常用第一种因为进销存系统对查询性能有要求。你需要在goods_stock表里确认唯一索引是不是建在(goods_id, warehouse_id, tenant_id)上。如果不是赶紧加上否则并发出入库时会出现同一商品同一仓库有多行库存记录后面扣减库存时你都不知道该扣哪一行。扣减库存的 SQL 也有讲究。很多人写成先SELECT查库存再UPDATE扣减这在并发下必然出问题。正确做法是用一条带条件的UPDATE-- 扣减库存只有库存充足时才执行更新返回影响行数 UPDATE goods_stock SET stock_num stock_num - #{quantity}, update_time NOW() WHERE goods_id #{goodsId} AND warehouse_id #{warehouseId} AND tenant_id #{tenantId} AND stock_num #{quantity};执行后检查返回的影响行数如果是 0说明库存不足直接抛业务异常回滚事务。参数说明quantity是本次出库数量stock_num #{quantity}这个条件就是防负库存的关键。别小看这一行我见过太多项目因为漏了这个条件在大促时把库存扣成负数最后只能人工对账。3. 条码扫描出入库从扫码枪到库存更新的完整链路3.1 扫码枪输入的本质是键盘事件很多人以为扫码枪需要专门的驱动或 SDK其实绝大多数 USB 扫码枪的工作模式就是「模拟键盘输入」——扫到条码后它会把条码内容当成键盘按键敲进当前焦点输入框最后补一个回车。所以前端要做的不是调什么硬件接口而是监听输入框的回车事件。在进销存系统里扫码场景通常分两种一种是入库时扫商品条码自动带出商品信息并累加数量另一种是出库时扫条码自动匹配订单行并扣减库存。下面是一个 Vue 页面里处理扫码输入的典型写法// 扫码输入框监听回车事件回车即触发条码解析 export default { data() { return { barcodeInput: , // 扫码枪输入的内容 scanLock: false // 防止重复提交的锁 }; }, methods: { handleScanEnter() { // 扫码枪扫完后会自动触发回车这里做防抖 if (this.scanLock || !this.barcodeInput.trim()) return; this.scanLock true; // 调用后端接口根据条码查询商品信息 this.$axios.post(/api/stock/scan, { barcode: this.barcodeInput.trim(), warehouseId: this.currentWarehouseId, type: IN // IN 入库OUT 出库 }).then(res { if (res.data.code 200) { // 扫码成功刷新当前单据的商品列表 this.refreshGoodsList(res.data.data); this.barcodeInput ; // 清空输入框准备下一次扫码 } else { this.$message.error(res.data.msg); // 比如「该条码不存在」 } }).finally(() { this.scanLock false; // 无论成功失败都释放锁 }); } } };逻辑说明barcodeInput绑定到输入框的v-model扫码枪输入的内容会实时同步到这个变量。keyup.enter触发handleScanEnter方法。scanLock是防止连续扫码时前一个请求还没返回就发第二个请求导致库存扣减重复。参数说明warehouseId是当前操作的仓库必须从前端传过来因为同一个条码在不同仓库可能对应不同的库存记录type区分入库还是出库后端根据这个字段决定是加库存还是减库存。后端接口收到条码后先查goods_barcode表找到对应的goods_id再根据goods_id和warehouse_id去goods_stock表里更新库存。这里有个坑一个商品可能有多个条码比如不同规格不同条码所以goods_barcode表要允许一个goods_id对应多条记录。3.2 扫码入库时如何避免重复扫描同一件商品实际仓库操作中操作员可能不小心对同一件商品扫了两次。如果系统不做校验库存就会多加一件。解决办法是在扫码接口里加一个「本次单据已扫条码」的缓存。常见做法是用 Redis 的 Set 结构key 是单据号value 是已扫的条码集合。每次扫码前先SISMEMBER判断如果已存在就提示「该商品已扫描」否则SADD进去再执行库存更新。下面是对应的 Redis 操作# 扫码前检查条码是否已在本单据中 SISMEMBER scan:order:20250101:001 6901234567890 # 返回 1 表示已扫过返回 0 表示未扫过 # 如果未扫过加入集合并设置过期时间比如 24 小时 SADD scan:order:20250101:001 6901234567890 EXPIRE scan:order:20250101:001 86400参数说明scan:order:20250101:001是单据维度的 key20250101:001是单据号86400是过期时间单位秒防止 Redis 里堆积大量已完成单据的 key。注意这个方案只适合单机 Redis 或主从模式如果用了 Redis ClusterSISMEMBER和SADD要在同一个 key 上操作没问题但EXPIRE要确保 key 存在时才设置。如果项目没上 Redis也可以用数据库的唯一索引兜底在stock_record表上建(order_no, barcode)的唯一索引重复插入时数据库会报错后端捕获这个异常并提示用户。缺点是并发高时数据库压力大但小规模仓库够用了。4. 源码部署避坑从导入数据库到跑通第一个扫码流程4.1 数据库导入时字符集和时区这两个参数必须改拿到源码后第一步是导入 SQL 文件。很多人直接source xxx.sql就完事结果跑起来发现中文乱码或者时间差 8 小时。问题出在字符集和时区上。导入前先确认 MySQL 的字符集配置。在my.cnf或my.ini里加上[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_general_ci default-time-zone08:00参数说明utf8mb4而不是utf8因为utf8在 MySQL 里只支持 3 字节存不了 Emoji 和某些生僻字default-time-zone08:00是东八区不设的话 Java 应用取到的时间会比实际少 8 小时。改完配置重启 MySQL再重新导入 SQL 文件。导入时用mysql -u root -p --default-character-setutf8mb4 dbname xxx.sql显式指定字符集避免客户端默认字符集和服务器不一致。4.2 扫码接口返回 404 的排查顺序部署完跑第一个扫码流程时最常见的问题是前端调/api/stock/scan返回 404。排查顺序如下先看后端日志有没有收到请求。如果日志里完全没有这条请求的记录说明请求根本没到后端问题在 Nginx 或前端代理配置。检查 Nginx 的location /api/是否转发到了正确的后端端口。如果后端日志有请求记录但返回 404说明 Controller 的RequestMapping路径和前端请求路径对不上。常见原因是源码里写的是/stock/scan但前端配了/api前缀而 Nginx 没有去掉这个前缀。解决办法是在 Nginx 里加rewrite ^/api/(.*)$ /$1 break;或者在后端application.yml里设置server.servlet.context-path/api。如果路径对得上但返回 405Method Not Allowed检查 Controller 方法上的PostMapping和前端axios.post是否一致。我见过有人前端用GET请求扫码接口后端只写了PostMapping结果一直 405查了半天。5. 多商户云进销存的避坑与排查5 个血泪教训5.1 现象A 商户登录后看到了 B 商户的库存数据原因共享表方案下某个查询 Mapper 漏写了tenant_id条件。常见于统计报表类接口因为报表 SQL 往往是手写的容易漏。解决在 MyBatis 拦截器里统一注入tenant_id同时写一个单元测试用两个不同商户的账号分别调用所有查询接口断言返回数据不重叠。这个测试跑一遍只要几秒钟但能挡住 90% 的越权问题。5.2 现象扫码入库时提示「库存更新成功」但库存没变原因事务没生效。扫码接口里先更新了stock_record流水表再更新goods_stock库存表但方法上没有加Transactional或者加了但异常被try-catch吞掉了导致流水插入了但库存没扣。解决在 Service 方法上加Transactional(rollbackFor Exception.class)注意是rollbackFor Exception.class而不是默认的RuntimeException因为有些业务异常继承的是Exception。另外catch 块里要么重新抛出异常要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。5.3 现象两个仓库同时出库同一商品库存扣成了负数原因扣减库存的 SQL 没有加stock_num #{quantity}条件或者加了但隔离级别是READ COMMITTED两个事务同时读到库存为 10各自扣 8最后变成 -6。解决用UPDATE ... WHERE stock_num #{quantity}这种原子操作数据库的行锁会保证只有一个事务能成功。另一个事务影响行数为 0直接抛异常回滚。不需要把隔离级别调到SERIALIZABLE那样性能太差。5.4 现象扫码枪扫出来的条码前面多了一个字符原因扫码枪的键盘布局设置不对。有些扫码枪默认是美式键盘布局扫某些特殊字符时会多输出一个前缀。解决找到扫码枪的说明书扫一下「恢复出厂设置」的条码然后扫「键盘布局中文」的条码。如果找不到说明书在输入框里加一个正则过滤只保留数字和字母barcode.replace(/[^0-9a-zA-Z]/g, )。但这只是兜底最好还是把扫码枪配对。5.5 现象系统跑了一段时间后库存流水和库存表对不上原因有人直接改了数据库里的goods_stock表没有走系统接口导致流水表没有记录这次变更。解决给goods_stock表加一个触发器任何UPDATE操作都往stock_record里插一条「手动调整」类型的流水。或者更简单数据库账号权限收紧业务系统用的账号只给SELECT、INSERT、UPDATE不给DELETE禁止直接改库存表。我一般会在项目交接文档里写一句谁直接改库存表谁负责对账。6. 用定时任务做库存日结与差异告警源码跑通之后真正让这套系统在生产环境站住脚的是一个每天凌晨执行的库存日结任务。它的作用不是算库存——库存是实时更新的——而是做两件事一是把当天的出入库流水汇总成日报二是比对流水汇总值和库存表当前值不一致就告警。下面是一个基于 SpringScheduled的日结任务示例Component public class StockDailyCheckTask { Autowired private StockRecordMapper stockRecordMapper; Autowired private GoodsStockMapper goodsStockMapper; Autowired private AlertService alertService; // 每天凌晨 2 点执行 Scheduled(cron 0 0 2 * * ?) public void dailyCheck() { // 1. 查询所有商户所有仓库的商品 ListGoodsStock stockList goodsStockMapper.selectAll(); for (GoodsStock stock : stockList) { // 2. 根据流水表汇总当日库存变动 Integer flowSum stockRecordMapper.sumByGoodsAndWarehouse( stock.getGoodsId(), stock.getWarehouseId(), stock.getTenantId(), LocalDate.now().minusDays(1)); // 3. 如果流水汇总不为空且和当前库存不一致发告警 if (flowSum ! null !flowSum.equals(stock.getStockNum())) { alertService.send(库存差异告警, String.format(商户 %d 仓库 %d 商品 %d流水汇总 %d库存表 %d, stock.getTenantId(), stock.getWarehouseId(), stock.getGoodsId(), flowSum, stock.getStockNum())); } } } }逻辑说明Scheduled(cron 0 0 2 * * ?)表示每天凌晨 2 点执行cron 表达式六个字段分别是秒、分、时、日、月、周。sumByGoodsAndWarehouse是一个自定义的 Mapper 方法SQL 大概是SELECT SUM(change_num) FROM stock_record WHERE ... AND create_time #{date}。参数说明LocalDate.now().minusDays(1)是统计昨天的流水因为任务在凌晨执行当天的流水还没产生。AlertService可以对接邮件、钉钉或企业微信看团队习惯。这个任务的价值在于它不阻止任何业务操作但能在第二天早上告诉你「昨天有笔账对不上」。我自己的习惯是新项目上线第一个月每天看一次告警邮件把每一笔差异都追到具体单据。追了大概两周之后差异就归零了后面改成每周看一次。这套源码给了你一个起点但库存准不准最终还是靠这种笨办法盯出来的。希望帮到你。本文还有配套的精品资源点击获取