
简介MF00730-多商户多仓库带扫描云进销存源码是一套面向中小型企业及集团化运营团队的企业管理解决方案重点解决多商户独立核算、多仓库协同调度与库存实时监控等痛点适合具备一定PHP开发能力、需要二次定制进销存系统的开发者与运维人员。压缩包共1319个文件约22.05MB以509个PHP业务逻辑文件为核心辅以206个JS脚本、53个CSS样式与42个HTML页面构建前后端交互另有208个PNG、68个GIF等图片资源及SQL、CSV、XLS等数据文件目录结构完整便于按模块检索与部署。资源涵盖多商户账号隔离、多仓库出入库与调拨、扫描录入、库存预警与盘点、采购销售全程跟踪以及销售、库存、采购报表分析等模块云端同步保障数据安全与实时性。目前已有96人学习下载可作为进销存类项目二次开发与功能扩展的参考底本。1. 多商户多仓库带扫描云进销存一套源码到底解决了谁的账做批发、做连锁、做跨境小 B 分销的人几乎都遇到过同一个场景货在三个仓库老板在第四个城市业务员拿着手机在客户现场客户问「这个 SKU 还有没有货」业务员只能打电话回仓库问。等问清楚单子已经被隔壁同行签走了。多商户多仓库带扫描云进销存源码要解决的就是这个链路——把「商户—仓库—商品—单据—扫码」这五件事塞进一套能私有化部署的系统里让库存数字实时可信让扫码枪和手机摄像头都能当录入入口。这套源码的定位不是给个人记账用的它面向的是 SaaS 服务商、区域代理商、有多个门店或分仓的贸易公司。多商户意味着同一套部署里能开多个租户数据互相隔离多仓库意味着库存要按仓库维度拆开算扫描意味着出入库、盘点、调拨都能用条码枪或摄像头完成而不是手敲货号。云进销存则说明它是 B/S 架构浏览器和移动端都能访问。把这四个词拆开看每一个都对应一块真实的技术工作量下面按落地顺序讲清楚。2. 多商户数据隔离租户 ID 怎么切才不串数据多商户是这套系统里最容易翻车的地方。很多开源进销存只做了「多用户」没做「多租户」结果 A 商户能看到 B 商户的商品列表。真正的多商户要在数据层就把租户边界划死而不是靠前端隐藏菜单。2.1 三种隔离方案与选型理由常见做法有三种独立数据库、独立 schema、共享表加 tenant_id 字段。独立数据库隔离最彻底但一个租户一套连接池几十个商户就把数据库连接数吃满了运维成本高。独立 schema 在 MySQL 里约等于独立库问题一样。共享表加 tenant_id 是绝大多数云进销存的选择成本低、扩容方便代价是每条 SQL 都必须带租户条件一旦漏写就是数据泄露。我一般会选共享表方案但加两道保险第一道是 MyBatis 拦截器自动拼 tenant_id第二道是数据库层面对核心表建联合索引把 tenant_id 放在最左列。这样即使有人手写 SQL 忘了带条件索引扫描也能暴露出问题配合代码审查能兜住大部分风险。2.2 用 MyBatis 拦截器自动注入租户条件下面这段是拦截器的核心逻辑基于 MyBatis 的 Interceptor 接口实现对所有查询和更新自动追加租户条件。Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; // 只处理需要租户隔离的 mapper系统表和字典表跳过 if (!ms.getId().contains(TenantAware)) { return invocation.proceed(); } Object parameter invocation.getArgs()[1]; BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql(); // 避免重复拼接也避免 union 子查询被误伤 if (sql.contains(tenant_id)) { return invocation.proceed(); } Long tenantId TenantContext.getCurrentTenantId(); if (tenantId null) { throw new IllegalStateException(租户上下文为空拒绝执行); } String newSql sql AND tenant_id tenantId; // 通过反射改写 BoundSql实际项目里建议用更稳妥的 SQL 解析器 Field field BoundSql.class.getDeclaredField(sql); field.setAccessible(true); field.set(boundSql, newSql); return invocation.proceed(); } }逻辑说明拦截器只对命名里带TenantAware的 mapper 生效系统字典、地区表这类公共数据不隔离。参数说明TenantContext是一个 ThreadLocal 容器在网关或过滤器里从 JWT 或 session 中解析出 tenant_id 后塞进去请求结束必须 remove否则线程池复用会串租户。tenant_id用 Long 类型不要用字符串联合索引效率差一截。提示直接字符串拼接 SQL 有注入风险生产环境建议换成 JSqlParser 解析后改写或者用 MyBatis-Plus 的 TenantLineInnerInterceptor它已经处理了 union、子查询、insert 等边界情况。2.3 租户上下文从登录到落库的完整链路光有拦截器不够租户 ID 得从请求头一路传到数据库。典型链路是登录接口校验账号密码后签发 JWTpayload 里带 tenantId网关或 Spring 拦截器解析 JWT把 tenantId 放进 TenantContextService 层调用 mapper 时拦截器自动读取。这里有个坑异步任务和定时任务没有 HTTP 请求上下文TenantContext 是空的必须手动 set 再执行执行完 clear。public void asyncImportStock(Long tenantId, ListStockItem items) { try { TenantContext.setCurrentTenantId(tenantId); stockMapper.batchInsert(items); } finally { TenantContext.clear(); } }参数说明batchInsert的 SQL 里不要写 tenant_id 的值交给拦截器统一注入避免两处维护。批量插入时拦截器只处理一次 SQL性能损耗可以忽略。3. 多仓库库存模型把「一个 SKU 一个库存数」拆成三层多仓库是第二个硬骨头。单仓库系统里库存就是商品表的一个字段多仓库之后这个字段必须拆掉否则调拨、盘点、分仓可用量全乱。3.1 库存三层模型与表结构设计我一般把库存拆成三层商品 SKU 层、仓库层、库位层。SKU 层记录商品基础信息仓库层记录每个 SKU 在每个仓库的数量库位层记录具体货架位置。中小商户用两层就够库位层等仓库面积大了再加。核心表结构如下表名关键字段说明product_skuid, tenant_id, sku_code, name, unit商品档案条码存在 sku_codewarehouseid, tenant_id, name, type仓库type 区分自营/代发/在途stockid, tenant_id, sku_id, warehouse_id, qty, locked_qty库存主表qty 是可用量stock_logid, tenant_id, sku_id, warehouse_id, change_qty, biz_type, biz_no流水所有变动必须留痕qty是可用库存locked_qty是被订单锁定的数量实际可售等于 qty 减 locked_qty。这个设计能避免超卖下单时先锁库存支付后扣减取消订单再释放。3.2 库存扣减的并发控制与 SQL 写法库存扣减是并发重灾区。两个业务员同时给同一个 SKU 下单如果先查再改必然超卖。正确做法是用带条件的 UPDATE把判断和扣减合成一条原子语句。UPDATE stock SET qty qty - #{changeQty}, locked_qty locked_qty #{changeQty}, update_time NOW() WHERE tenant_id #{tenantId} AND sku_id #{skuId} AND warehouse_id #{warehouseId} AND qty - locked_qty #{changeQty};逻辑说明WHERE里的qty - locked_qty changeQty是防超卖的关键返回影响行数为 0 就说明库存不足业务层直接抛异常回滚。参数说明changeQty是本次要锁定的数量正数释放库存时把加减反过来写另一条 SQL。这条语句依赖(tenant_id, sku_id, warehouse_id)的唯一索引没有索引在高并发下会锁表。注意不要用SELECT ... FOR UPDATE再 UPDATE 的两步写法行锁持有时间长并发一上来就排队。单条原子 UPDATE 是更稳的选择。3.3 调拨与盘点的库存流水一致性调拨是两个仓库之间的一进一出必须放在同一个事务里否则出现「A 仓库扣了B 仓库没加」的脏数据。盘点则是把系统库存和实物对齐差异部分生成调整流水。这两类操作都要写stock_logbiz_type分别记TRANSFER和CHECKbiz_no用单据号方便日后对账。Transactional(rollbackFor Exception.class) public void transfer(Long fromWh, Long toWh, Long skuId, int qty, String bizNo) { int out stockMapper.decrease(fromWh, skuId, qty); if (out 0) throw new BizException(源仓库库存不足); stockMapper.increase(toWh, skuId, qty); stockLogMapper.insert(fromWh, skuId, -qty, TRANSFER, bizNo); stockLogMapper.insert(toWh, skuId, qty, TRANSFER, bizNo); }参数说明decrease和increase内部就是上面那条原子 UPDATE 的变体。事务注解必须加rollbackFor Exception.class否则受检异常不会回滚。调拨单号bizNo建议用「仓库编码 日期 序列」的格式人工排查时一眼能看出流向。4. 扫描录入条码枪和摄像头怎么接进同一套接口扫描是这套源码的体验分水岭。条码枪本质是键盘输入设备扫一下等于快速敲了一串字符加回车手机摄像头扫码则要走图像识别。两者最终都要落到同一个后端接口前端做适配层。4.1 条码枪的键盘事件处理与防重复条码枪在 PC 端表现为键盘输入扫一次会瞬间输入十几个字符然后回车。如果页面上有输入框聚焦字符会直接进去容易和手工输入混淆。常见做法是监听全局 keydown用时间间隔判断是扫码还是手敲——扫码的字符间隔通常在 30 毫秒以内。let buffer ; let lastTime 0; document.addEventListener(keydown, (e) { const now Date.now(); // 间隔超过 100ms 认为是人工输入清空缓冲 if (now - lastTime 100) buffer ; lastTime now; if (e.key Enter buffer.length 6) { handleScan(buffer); // 条码长度一般大于 6 buffer ; e.preventDefault(); } else if (e.key.length 1) { buffer e.key; } });逻辑说明buffer累积字符回车时如果长度超过 6 就判定为扫码。参数说明100 毫秒这个阈值要按条码枪型号调快的枪 20 毫秒慢的 50 毫秒设太大容易把连续手敲误判成扫码。handleScan里要做防重复同一码 500 毫秒内只处理一次避免枪连发。4.2 摄像头扫码的选型与降级策略移动端扫码常见做法是用 html5-qrcode 或 zxing-js 这类库调用 getUserMedia 拿摄像头流逐帧解码。选型时重点看三点是否支持一维码、弱光下识别率、包体积。html5-qrcode 体积小、API 简单适合进销存这种以 Code128 和 EAN13 为主的场景。import { Html5Qrcode } from html5-qrcode; const scanner new Html5Qrcode(reader); scanner.start( { facingMode: environment }, // 优先后置摄像头 { fps: 10, qrbox: { width: 250, height: 150 } }, (decodedText) { handleScan(decodedText); }, (err) { /* 解码失败静默不要弹窗刷屏 */ } );参数说明fps: 10是识别帧率太高费电太低扫不动qrbox是识别区域一维码要宽扁形二维码用正方形。降级策略是摄像头权限被拒或识别失败三次后自动切回手动输入框别让业务卡死。4.3 扫码结果落到出入库单的接口约定扫码只是拿到一串码真正干活的是后端。统一接口设计成POST /api/scan/resolve入参是条码和当前单据类型返回 SKU 信息、默认仓库、可用库存。前端拿到后填充单据行用户确认数量再提交。{ barcode: 6901234567890, bizType: PURCHASE_IN, warehouseId: 12 }返回里要带availableQty让业务员当场知道这个仓还有多少货避免扫了才发现没库存。bizType决定后续走哪个校验分支采购入库不校验库存销售出库必须校验。5. 避坑与排查多商户多仓库扫描系统最容易翻车的五件事这套系统上线后问题往往不在功能本身而在边界场景。下面五条是我踩过的真实坑按「现象 → 原因 → 解决」写。现象一A 商户登录后看到 B 商户的商品。原因某条统计 SQL 手写时漏了 tenant_id 条件拦截器因为 mapper 命名不规范没生效。解决统一 mapper 命名规范所有业务 mapper 必须带TenantAware后缀再加一条单元测试用两个租户上下文跑同一批查询断言结果不重叠。现象二大促时库存出现负数。原因扣减用了先查后改的两步写法并发下判断失效。解决全部改成带qty - locked_qty changeQty条件的原子 UPDATE检查影响行数。上线前用 JMeter 压 200 并发下单确认无负库存。现象三调拨单只成功了一半。原因调拨方法没加事务或者加了但异常类型没配 rollbackFor。解决Transactional(rollbackFor Exception.class)并且把两个仓库的扣减和流水写入放在同一个方法内不要跨 Service 调用导致事务失效。现象四条码枪扫出来的码多了前缀字符。原因条码枪出厂配置带了自定义前缀或者键盘布局设置成了非英文。解决用扫码枪的配置手册扫「恢复出厂设置」码再扫「USB 键盘模式」码代码侧在handleScan里做一次 trim 和前缀剥离。现象五手机扫码在弱光下识别不出来。原因摄像头曝光不足或者识别区域设得太小。解决引导用户开闪光灯qrbox宽度调到屏幕宽度的 70% 以上一维码识别对角度敏感提示用户让条码水平对准取景框。6. 从能跑到能扛库存对账与压测的两个进阶技巧系统能跑起来只是第一步能不能扛住真实业务量要看对账和压测。这里分享两个我常用的技巧。第一个是库存对账。每天凌晨跑一次定时任务把stock表的数量和stock_log的流水汇总做比对不一致的 SKU 生成差异报表。流水汇总的 SQL 是这样SELECT sku_id, warehouse_id, SUM(change_qty) AS log_qty FROM stock_log WHERE tenant_id #{tenantId} AND create_time #{startTime} AND create_time #{endTime} GROUP BY sku_id, warehouse_id HAVING log_qty ( SELECT qty FROM stock s WHERE s.sku_id stock_log.sku_id AND s.warehouse_id stock_log.warehouse_id AND s.tenant_id stock_log.tenant_id );逻辑说明流水汇总和库存主表对不上说明有操作没写流水或者事务回滚不干净。参数说明时间范围按天传避免全表扫描HAVING里做子查询在数据量大时会慢可以先把库存主表捞进临时表再 join。这个对账任务帮我抓到过两次「扣了库存没写流水」的 bug都是因为某段代码绕过了统一的库存服务直接改表。第二个是压测。多商户多仓库系统的瓶颈通常在库存扣减和租户拦截器。压测时不要只压登录接口要构造真实的下单链路登录拿 token、查商品、锁库存、提交订单。用 JMeter 的 CSV 参数化准备 500 个 SKU 和 3 个仓库200 并发跑 10 分钟重点看三个指标库存扣减的 TPS、租户拦截器的耗时占比、数据库连接池等待时间。如果拦截器耗时超过 5 毫秒说明 SQL 解析开销大考虑换成 MyBatis-Plus 的租户插件或者缓存解析结果。我自己的习惯是任何涉及库存的改动上线前必须跑一遍对账脚本加一轮压测两个都过了才敢发。这套源码的价值不在于功能多全而在于它把多商户隔离、多仓库库存、扫描录入这三块最容易出事的逻辑摆在了明面上照着改比从零搭省至少两个月。希望帮到你。本文还有配套的精品资源点击获取