ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多商户多仓库云进销存源码实战:扫码出入库与SaaS落地

多商户多仓库云进销存源码实战:扫码出入库与SaaS落地 简介这是一套2022年发布的多商户、多仓库云进销存ERP管理系统源码内置扫描功能与SaaS营销版能力支持无限商户入驻主要面向需要搭建电商后台或批发零售管理系统的开发者、企业及第三方技术服务商可有效解决多门店库存同步、订单流转、商户分账和营销推广等一体化管理难题。资源包为RAR压缩格式总容量21.38MB共包含1317个文件其中以509个PHP核心文件为主配合206个JS脚本、53个CSS样式、208个PNG图片与68个GIF动图分别承担业务逻辑、前端交互和界面展示同时附带SQL数据库脚本、ZIP备份包、配置说明及少量字体、证书等辅助资源便于部署和二次调试。目前已有920人学习/下载说明这套源码具备一定的市场参考价值。解压后可获得完整程序目录、安装与配置指引、初始数据表结构、变更日志与历史备份并集成第三方组件和基础素材整体结构清晰便于按需裁剪、部署演示或直接投入商业项目使用适合具备PHP基础、希望快速搭建多商户多仓库进销存运营平台的读者作为起点。1. 多商户多仓库云进销存源码带扫描、SaaS 与无限商户怎么落地做 ERP 外包这几年我收到最多的需求不是写个进销存而是客户要一个云进销存系统总部看全盘每个加盟店自己管库存还要扫码枪直接出入库。单商户源码装十套就是十个项目数据还不互通售后能把人拖垮。这次拆的这套多商户多仓库云进销存 ERP 管理系统源码核心卖点正好打在 SaaS 上一套程序开无限商户每个商户下挂多个仓库扫描枪录入出入库后台做营销配置。适合做 ERP 二次开发的从业者拿到底子改需求也适合正在选型 SaaS 化进销存的人先看权限设计和库存模型。下面按我从拆包到跑通业务的实际顺序写每一步都能照着复现。2. 源码结构与 SaaS 多商户边界目录、数据表、权限关系一次理清2.1 拆包后目录结构怎么读拿到源码不要急着传服务器先把目录整体过一遍。这套源码虽然是 2022 版本但目录组织和现在主流 PHP 进销存项目差不多无非是后台、接口、安装引导、数据库脚本这几块。典型结构如下├─ admin # 后台管理入口模块 │ ├─ controller/ # 控制器处理后台上架、出库、报表等 │ ├─ model/ # 模型层封装数据读写 │ └─ view/ # 模板文件登录页、商户管理页、小票打印模板 ├─ api # 接口模块供小程序、H5、扫码枪调用 ├─ install # 安装引导部署时最先访问 ├─ public # Web 根目录 │ ├─ index.php # 单一入口 │ └─ upload/ # 商品图片、营业执照等上传目录 ├─ runtime # 框架运行缓存与日志必须可写 └─ database └─ install.sql # 全量初始化脚本含菜单、管理员和演示数据判断这套源码值不值得继续看我会先做两件事第一打开 database/install.sql 数一下业务表数量看有没有 merchant、warehouse、stock_bill 这类核心表标题里的多商户、多仓库必须落到表设计上不是控制器里写死第二扫一眼 admin/controller 下的文件命名确认有没有公共基类比如 BaseController、BaseApi这是后文加商户隔离条件的关键位置。实际拿到的包可能会多一层 vendor 或第三方依赖目录但主线不会变。admin 走页面、api 走接口、install 只在第一次有用。真正的二次开发重点在 model 层因为多商户系统里所有的查询和写入都要经过模型把权限过滤收敛到模型层比在每个控制器里手写 where 条件要抗踩坑得多。2.2 多商户与多仓库的数据模型merchant_id 是隔离核心多商户系统最怕的就是租户数据串了。这套源码的业务表基本都带 merchant_id 字段比如商户表、商品表、库存表、出入库流水表。核心表大致如下表名典型关键字段用途merchantid, name, expire_time, status商户基础信息expire_time 控制可用时长warehouseid, merchant_id, name, code仓库表每个商户下挂多个仓库goodsid, merchant_id, barcode, name, price商品表barcode 是扫码匹配依据stockid, goods_id, warehouse_id, quantity库存表商品与仓库唯一确定一条记录stock_billid, merchant_id, warehouse_id, type, goods_id, qty出入库流水每次扫码变动写一条账只要 merchant_id 在查询条件里出现一次租户隔离就成立了一半。以查询某商户的仓库列表为例正确写法是public function getWarehouseList($merchantId) { return WarehouseModel::where(merchant_id, $merchantId) -where(status, 1) -field(id, name, code) -select(); }这里的 $merchantId 来自登录态的 merchant_id而不是前端传入的商户 id。如果你把前端传来的 id 直接带进查询别人改一下 URL 参数就能看到别的商户的仓库。更稳妥的做法是写一个公共模型方法自动追加当前登录商户条件protected function getMerchantScope() { return [merchant_id session(merchant_id)]; }然后把列表查询统一改成$this-where($this-getMerchantScope())-select()。这样不管后端哪个位置漏了条件至少模型层还能兜底一次。我后来在排查客户数据串扰问题时发现乱象都是出在联表查询没带全租户条件后面避坑章节会单独说一条。2.3 SaaS 营销版的套餐与无限商户控制逻辑标题里写的无限商户实际不是数据库里没有上限而是商户数量不设硬编码靠套餐表和授权标识限制。商户表一般有 package_id 和 expire_time后台给商户分配套餐后登录和调接口时都要校验商户是否在有效期内。商户自助注册时代码会在一个事务里同时写入商户表和套餐关联表。典型校验逻辑长这样public function checkMerchantActive($merchantId) { $merchant MerchantModel::find($merchantId); if (!$merchant || $merchant-status ! 1) { return false; } if ($merchant-expire_time date(Y-m-d H:i:s)) { return false; // 到期后后台和 API 全部拒掉 } return true; }这段 check 会被 api 模块的公共基类在请求分发前调用扫码、下单、库存查询接口全部要先过这一关。真正控制无限商户的往往在授权认证模块里也就是常说的 license 机制会在 install 时写入域名或 IP 标识。这类源码的套餐费用策略在后台可以自由设置比如按月、按年、按商户数分档这部分属于运营配置源码里通常都有对应的套餐管理菜单。3. 部署上线从环境配置到首个商户开通的完整操作3.1 环境要求与伪静态配置这套源码是典型的 PHP MySQL 应用建议用 Nginx PHP 7.x MySQL 5.7 起步。PHP 版本不要上 8.1老代码里有些函数在 PHP 8 里被废弃会出现白屏这种难排查的问题。我一般直接用现成的集成环境或 Docker 起 LEMP先确认三件套版本php -v mysql -V nginx -v版本确认没问题后把源码放到站点根目录先配伪静态。Nginx 下需要把不含真实文件的路径全部转发给入口 index.php这就是框架路由的基础location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }伪静态这段配置在宝塔面板里可以直接在站点设置里开伪静态模板选择 thinkphp 即可。如果你用的是 Apache规则要换成 .htaccess 的 RewriteRule逻辑等价。这一步最容易出的问题不是写错规则而是开了伪静态但 PHP 运行模式不匹配导致所有动态页面都 404。判断方法是先看静态资源能不能打开能打开说明 Nginx 本身正常问题一定出在 rewrite 或者 PHP-FPM 的 SCRIPT_FILENAME 上。3.2 数据库导入与配置文件修改安装包里 database/install.sql 是完整初始化脚本包含所有菜单、默认管理员账号和演示数据。导入前先建库字符集务必用 utf8mb4否则商品名里遇到 emoji 或者特殊符号会直接变成问号后续搜索也受影响。mysql -uroot -p -e CREATE DATABASE erp_cloud DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p erp_cloud database/install.sql导入完成后改数据库连接配置。这套源码的配置格式和主流 ThinkPHP 完全一致关键项如下// config/database.php return [ hostname 127.0.0.1, database erp_cloud, username root, password your_password, hostport 3306, prefix erp_, ];这里的 prefix 非常关键。有些版本在安装向导里会让你填表前缀如果填了自定义前缀模型里的表名也要保持一致。最典型的翻车是你用了 erp_ 前缀但模型里写死的表名没有带前缀或者两个表带前缀、三个表不带结果登录后台能看到菜单但商品、仓库这些列表页全报数据表不存在。配置改完后runtime 和上传目录必须给写权限否则框架报日志错误上传商品图片直接失败chmod -R 777 runtime chmod -R 777 public/upload如果你拿到的是带 .env 配置的版本数据库信息写在 .env 文件里那就改 .env 而不是 config.php。我习惯两边都看一眼因为有些代码是从 .env 读配置有些则直接兼容 config.php漏一个就白改。3.3 初始化后台并开通第一个商户数据库导入完成后访问你站点域名下的 /admin 地址通常会自动跳进安装引导页。按页面提示填数据库信息、管理员账号密码即可。正常情况下安装向导会自动写入 install.lock 文件防止重复安装。如果没有生成我建议手动在 install 目录下建一个空文件避免后续误访问 install 页面把数据重置。这个动作虽然土但能防止很多事故。开通商户的位置在后台菜单商户管理里点添加商户后除了填商户名称还要给它分配一个套餐否则商户登录时 checkMerchantActive 会直接返回 false永远无法进入后台。手动开通的核心 SQL 其实是两条先插商户再插套餐关联INSERT INTO merchant (name, status, expire_time, create_time) VALUES (测试商户1, 1, 2025-12-31 23:59:59, NOW()); INSERT INTO merchant_package (merchant_id, package_id, start_time, end_time) VALUES (LAST_INSERT_ID(), 1, NOW(), 2025-12-31 23:59:59);这里 package_id 要填后台套餐管理里真实存在的套餐 id否则关联不到套餐商户登录后功能菜单会很奇怪。手动插只是为了快速实验业务上线后应该走商户自助注册流程让代码在一个事务里把 merchant、merchant_package、初始仓库表一起写入避免数据缺胳膊少腿。部署完先别急着开客户用测试商户登录一遍看看能不能添加仓库、商品再把扫码枪插上测试。这一步复现顺利说明环境、配置、授权都通了后面接入真实业务才稳。4. 带扫描的进销存闭环扫码出入库与多仓库扣减实现4.1 扫码枪为什么不需要读硬件市面上的扫码枪绝大多数是 HID 键盘模式插上 USB 后系统把它识别成键盘扫描一个条码等于快速敲一串字符再加一个回车。所以代码层面完全不需要调用扫码硬件 SDK只要监听键盘输入就行。这套源码能做到带扫描本质就是靠这一点。理解这个机制你就明白为什么扫码模块可以在 api 层实现而不是设备层。前端页面拿到完整条码后把条码发给后端接口后端根据条码查商品、查仓库、写库存流水一步到位。这里最大的隐患其实是输入法如果电脑当前是中文输入法扫码枪敲进来的字符会被输入法拦截、组词要么丢字要么变成全角字符。所以凡是配扫码枪的操作电脑我都会强制把默认输入法切回英文这个坑在避坑章节还会再展开。4.2 前端监听回车与后端扫码接口扫码枪扫描一条条码后会自动回车所以最直接的前端实现是监听 keypress 事件收集非回车字符回车时提交完整条码。下面这段代码可以直接用在入库弹窗页面放在文档加载完成的脚本里let barcode ; let lastTime 0; document.addEventListener(keypress, function (e) { const now Date.now(); // 扫码枪字符间隔通常在 10ms 内手动录入间隔远大于 50ms if (now - lastTime 50) { barcode ; } lastTime now; if (e.key Enter) { handleBarcode(barcode); barcode ; } else { barcode e.key; } }); function handleBarcode(code) { const payload { barcode: code, type: in, // in 入库out 出库 warehouse_id: 1, merchant_id: 10001 // 实际项目中从登录态拿不要写死 }; fetch(/api/stock/scan, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }).then(res res.json()).then(data { if (data.code 1) { // 刷新当前页面的库存列表 } }); }这里的防抖关键在 lastTime 判断扫码枪的字符流很快手动键盘录入速度远达不到 50ms 间隔。如果你发现扫码时条码第一位被吞就把阈值调到 80ms如果手动录入也频繁触发扫码逻辑就调回 30ms。这个参数跟扫码枪品牌有关没有统一标准现场试几次就好。后端接口处理逻辑大概是这样我把注释写全方便你对着改public function stockScan() { $barcode input(barcode); $type input(type, in); $warehouseId intval(input(warehouse_id)); // 第一步查商品条码必须归属于当前商户 $goods GoodsModel::where(barcode, $barcode) -where(merchant_id, $this-merchantId) -find(); if (!$goods) { return json([code 0, msg 条码未匹配或不属于当前商户]); } // 第二步查当前仓库库存 $stock StockModel::where(goods_id, $goods-id) -where(warehouse_id, $warehouseId) -where(merchant_id, $this-merchantId) -find(); // 第三步变动数量并写流水 if ($type in) { $stock-quantity $stock-quantity 1; $stock-save(); } else { if ($stock-quantity 1) { return json([code 0, msg 库存不足]); } $stock-quantity $stock-quantity - 1; $stock-save(); } // 第四步记录出入库流水 BillModel::create([ merchant_id $this-merchantId, warehouse_id $warehouseId, goods_id $goods-id, type $type, qty 1, operator session(user_id), create_time date(Y-m-d H:i:s) ]); return json([code 1, msg ok]); }这个接口每一步都强制带上 merchant_id 和 warehouse_id。这两个条件任何一个缺失都有可能操作了别的商户或别的仓库的库存。你可能会问为什么出库时不直接做原子更新因为这里一次只扫一件商品先查再改看起来够用但真实业务里两个人同时扫到最后一件商品就一定会踩到超卖问题所以需要下一小节的处理。4.3 多仓库扣减的并发安全与批量处理真实场景里两个收银员同时出库同一件商品如果都用先查数量、再更新数量的写法两个请求可能同时读到 quantity1都判断库存够然后都扣减最终数量变成 -1这就是超卖。常见做法是改成数据库条件更新让行锁去挡并发UPDATE stock SET quantity quantity - 1 WHERE goods_id ? AND warehouse_id ? AND quantity 0;执行这条 Update 后再判断影响的行数。如果 affected rows 是 0说明库存已经被别人扣掉了直接返回库存不足不再做任何后续保存。入库逻辑同样可以写quantity quantity 1没有数量条件限制。这样即使并发几百个请求数据库行锁会把同一个商品在同一个仓库上的更新串行化不会再出现负数。批量出库时要格外注意不要把一个订单里的 N 个商品拆成 N 条独立 Update 去执行。如果循环中途一条失败前面扣了的库存不会自动回滚。正确做法是把所有扣减放进一个事务并给每个商品都做条件更新Db::startTrans(); try { foreach ($items as $item) { $affected StockModel::where(goods_id, $item[goods_id]) -where(warehouse_id, $warehouseId) -where(merchant_id, $merchantId) -where(quantity, , 0) -setDec(quantity, $item[qty]); if (!$affected) { throw new Exception(库存不足 . $item[goods_name]); } } Db::commit(); } catch (Exception $e) { Db::rollback(); return json([code 0, msg $e-getMessage()]); }如果源码本身是读写分离的数据库架构这种原子 Update 必须走主库不能读从库。从库拿到的数据可能延迟几秒秒杀场景下延迟 0.5 秒就足够卖超了。我在二次开发时会把所有写库存、写流水的逻辑收进同一个 service 类统一指定主库连接报表查询再走从库这样既不丢事务也不把读压力堆到主库。5. 避坑排查部署与跑业务最常见的五条踩坑记录5.1 安装页面白屏或一直转圈先查伪静态和 PHP 版本现象访问安装地址能打开静态图片但表单提交后白屏或者安装进度卡在 50% 不动浏览器控制台报 404。原因多数是 rewrite 规则缺失或 PHP 版本过高。Nginx 站点配置里没有写入 pathinfo 转发规则导致index.php?s/install/step2这类路径直接落 404。另外 PHP 8.0 以上版本会把老代码里each()、create_function()等废弃函数直接判错表现出来也是白屏。解决把第三章那段 Nginx rewrite 配置加到对应 server 块然后nginx -s reload。如果已经写了规则还白屏打开 runtime/log 下的日志看到 Primary script unknown 说明 fastcgi 的 SCRIPT_FILENAME 没配对看到 No input file specified 是 PHP 运行路径问题。我遇到一次最隐蔽的情况是集成环境里装了多个 PHP 版本站点用的还是 7.2但命令行php -v显示的是 8.1所以版本排查要以站点实际加载的为准别被命令行误导。5.2 A 商户登录后台看到 B 商户的商品查询条件漏了 merchant_id现象用商户 A 的账号登录商品列表里出现商户 B 的商品或者 A 的出库操作把 B 的库存减掉了。这种问题往往不是一开始就出现而是加到第二个商户以后才暴露。原因商品、库存、仓库都靠 merchant_id 隔离但仓库表里的 id 不是全局唯一两个商户的仓库可能都叫1号仓仓库 id 都是 1。如果查询只写了where(warehouse_id, 1)系统根本分不清这个 1 号仓是谁家的于是 A 商户的仓库 1 和 B 商户的仓库 1 就撞了。解决排查所有列表查询和保存操作的模型把 merchant_id 条件补上。给你一个能快速定位问题的 SQL把没按商户隔离的重复编号仓库找出来SELECT w.merchant_id, w.id AS warehouse_id, w.name, g.name AS goods_name FROM erp_warehouse w JOIN erp_goods g ON g.merchant_id w.merchant_id WHERE w.merchant_id ! g.merchant_id LIMIT 20;出现这种问题基本都是联表查询时没把租户条件带全。我一般在公共模型里强制追加当前商户条件让所有子类继承而不是在每个控制器里手动写 where。这样即使后边来了新同事接手直接调公共方法不太容易漏条件。5.3 扫码枪扫出来的条码少一位或末尾多空格中文输入法和扫码枪延时在捣鬼现象扫码枪扫同一张条码有时候完整有时候少最后两位有时候末尾多了一串问号。手动敲键盘完全正常只有扫码录入出错。原因扫码枪被识别成键盘后如果操作系统处于中文输入法扫描字符流里的字母数字会触发组词逻辑导致丢字或变全角。另一种情况是老式扫码枪字符间隔太短前端防抖把间隔小于阈值的字符当成了一次超快的手动输入也会丢码。解决给操作电脑切到英文输入法把中文输入法从默认列表里删掉扫码枪说明书里一般有设置延时的配置码把延时调到 10-20ms前端防抖阈值按实际设备调。我处理过一个更玄学的案例是扫码枪插在 USB Hub 上供电不足导致丢码换成机箱后置 USB 口就好了。这种问题排查顺序永远是先软件、再中间设备、最后换枪别一上来就改代码。5.4 库存扣成负数并发下单没有行锁现象同一商品在两个收银台同时扫码出库后台库存变成 -2但订单都成功了。平时单人操作正常一到高峰期就出问题。原因代码走的是先查库存判断大于 0再执行 update 扣减。这个先查后改在并发下是典型的竞态条件。两个请求都查到 quantity1都通过判断然后都扣就变 -1。解决把出库扣减改成条件更新让数据库行锁来保证安全$affected StockModel::where(goods_id, $goodsId) -where(warehouse_id, $warehouseId) -where(merchant_id, $merchantId) -where(quantity, , 0) -setDec(quantity, 1);setDec 返回 1 说明扣减成功返回 0 说明当前库存已经没了直接返回库存不足。批量出库要用事务包裹任何一个商品扣减失败就整体回滚别让部分扣减、部分未扣的脏数据留在库里。这套写法不仅适合扫码场景普通网页端下单也一样适用。5.5 无限商户版却提示授权过期域名绑定和 install 目录残留现象本地测试没问题上传到客户服务器后后台能登录但过一段时间提示授权过期或者创建第二个商户时被拒绝。重装一次能好一阵过几天又复发。原因这类标称无限商户的源码商户数量不设限制但真正的上限在授权认证里。授权通常绑定当前访问域名或服务器 IP本地 127.0.0.1 和客户域名不一致就会触发校验失败。还有一种是 install 目录没有锁安装向导被二次访问后重新覆盖了授权标识导致授权状态被重置。解决部署完成后去配置文件里确认授权域名是否与当前访问域名一致。部分实现会在 install 目录生成 license.key 文件里面明文写着绑定域名修改前先备份然后让程序重建缓存。更重要的是把 install 目录改名成 install_bak既能防止重复安装也能减少被探测风险。我已经把这个动作固定进部署清单每次上线必执行到现在没再出过授权类事故。6. 进阶小票打印、自动冒烟测试与两个常用改点6.1 替换小票模板和针式打印机对齐进销存系统上线后客户反馈最多的是打印对不齐。这套源码一般把打印模板放在 admin/view/print 下用 HTML 控制 58mm 或 80mm 宽度。改模板时不要只调字号核心是把所有字符统一为等宽字体再用函数截断字符串补足空格宽度。比如商品名称太长时用 mb_substr 限制到 10 个字符后面补空格到固定宽度这样价格列就能整体对齐。换纸宽后不用重新调样式只改容器宽度就行。6.2 用一段 Shell 脚本做上线前冒烟测试上线前我习惯跑一遍完整的建商品 - 扫码入库 - 扫码出库 - 查库存流程。手工点鼠标太慢可以写一个简单的 shell 脚本用 curl 打接口快速验证核心链路BASE_URLhttp://your-domain.com/api TOKEN$(curl -s -X POST $BASE_URL/login \ -H Content-Type: application/json \ -d {account:admin,password:admin123} | jq -r .token) # 建一个测试商品 curl -s -X POST $BASE_URL/goods \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {barcode:6901234567890,name:冒烟测试商品,price:10} | jq . # 入 3 件出 1 件最后库存应为 2 curl -s -X POST $BASE_URL/stock/scan \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {barcode:6901234567890,type:in,warehouse_id:1} | jq . curl -s -X POST $BASE_URL/stock/scan \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {barcode:6901234567890,type:out,warehouse_id:1} | jq .脚本里最该留意的是 token 是否真的拿到、warehouse_id 是否和测试商户匹配、最后库存字段是否符合预期。我通常会在脚本末尾加一段检查逻辑把库存接口的返回结果 grep 出来等于 2 才继续。从那以后我每次给客户部署完这套系统都会强制自己跑一遍这个脚本确认扫码、扣库存、写流水三条链路真的通了再交付后台账号。这个习惯帮我挡住了至少三次测试配置推到生产的事故希望帮到你。本文还有配套的精品资源点击获取
返回列表