简介: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 得从请求头一路传到数据库。典型链路是:登录接口校验账号密码后签发 JWT,payload 里带 tenantId;网关或 Spring 拦截器解析 JWT,把 tenantId 放进 TenantContext;Service 层调用 mapper 时拦截器自动读取。这里有个坑,异步任务和定时任务没有 HTTP 请求上下文,TenantContext 是空的,必须手动 set 再执行,执行完 clear。
public void asyncImportStock(Long tenantId, List<StockItem> items) { try { TenantContext.setCurrentTenantId(tenantId); stockMapper.batchInsert(items); } finally { TenantContext.clear(); } }参数说明:batchInsert的 SQL 里不要写 tenant_id 的值,交给拦截器统一注入,避免两处维护。批量插入时拦截器只处理一次 SQL,性能损耗可以忽略。
3. 多仓库库存模型:把「一个 SKU 一个库存数」拆成三层
多仓库是第二个硬骨头。单仓库系统里库存就是商品表的一个字段,多仓库之后这个字段必须拆掉,否则调拨、盘点、分仓可用量全乱。
3.1 库存三层模型与表结构设计
我一般把库存拆成三层:商品 SKU 层、仓库层、库位层。SKU 层记录商品基础信息,仓库层记录每个 SKU 在每个仓库的数量,库位层记录具体货架位置。中小商户用两层就够,库位层等仓库面积大了再加。
核心表结构如下:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| product_sku | id, tenant_id, sku_code, name, unit | 商品档案,条码存在 sku_code |
| warehouse | id, tenant_id, name, type | 仓库,type 区分自营/代发/在途 |
| stock | id, tenant_id, sku_id, warehouse_id, qty, locked_qty | 库存主表,qty 是可用量 |
| stock_log | id, 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_log,biz_type分别记TRANSFER和CHECK,biz_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 的租户插件或者缓存解析结果。
我自己的习惯是,任何涉及库存的改动,上线前必须跑一遍对账脚本加一轮压测,两个都过了才敢发。这套源码的价值不在于功能多全,而在于它把多商户隔离、多仓库库存、扫描录入这三块最容易出事的逻辑摆在了明面上,照着改比从零搭省至少两个月。希望帮到你。
本文还有配套的精品资源,点击获取