☰
多商户多仓库SaaS进销存源码:数据隔离、扫码库存与营销实战
2026/10/28 9:19:31 网站建设 项目流程

简介:这是一套多商户、多仓库的云进销存系统ERP管理源码,采用SaaS营销版设计,支持无限商户接入。系统面向需要部署进销存业务的企业开发者及SaaS服务商,核心解决多级组织架构下的数据协作:多企业间数据完全独立,每个企业可下设多子公司,子公司可挂多门店,门店下再管多个仓库,层级最多支持总公司-子公司-门店三级;总公司与子公司账号可切换至任意门店操作,不同门店间的仓库也支持调拨,业务单据按门店隔离、基础数据共享,权限模型清晰。压缩包共1317个文件,以509个PHP业务代码为主,配以206个JS前端脚本、53个CSS样式与42个HTML页面,另有PNG/GIF等UI素材、SQL数据库脚本及README等说明文件,整包约22.36MB,结构完整。目前已有231人学习下载,适合熟悉PHP的中高级开发者用于研究多商户SaaS架构、库存调拨与权限设计。

1. 多商户多仓库带扫描的SaaS进销存源码,能跑起来和能接业务是两回事

一套带多商户、多仓库、扫码出入库和营销功能的进销存 ERP 源码到手,多数人的第一反应是装个环境把界面跑出来。但真正决定这套源码能不能变成产品的是另外三件事:租户之间会不会串数据、扫码枪进库房好不好用、营销活动会不会把库存玩崩。这套源码面向的是一类具体需求:批发零售企业要管多仓库存,SaaS 服务商要做多租户云进销存,以及想基于 php 源码做二次开发的团队。它和单机版进销存最大的差别在于,所有单据都跨商户、跨仓库,也就是常说的云进销存。看着是无限商户、营销版的卖点,实际落地时决定成败的反而是数据隔离和库存维度。能跑起来和能接业务,在这里是两件完全不同的事。

2. 多商户与多仓库的数据模型:先把决定生死的两个维度定住

2.1 租户隔离:无限商户真正在隔离什么

"无限商户"是这类 SaaS 进销存源码最常见的卖点,但它在技术上只意味着一件事:不能给每个商户独立部署一套系统或者独立开一个数据库,否则服务器成本和维护成本会随着商户数线性上涨。绝大多数云进销存落地时会选共享库共享表加租户 ID 的方案,也就是在核心业务表上挂一个 merchant_id,业务层所有读写都按这个字段过滤。好处是商户扩容几乎不增加运维负担,坏处是只要有一处查询漏加租户条件,数据就串了。

最常见的隔离实现就是给模型挂全局 Scope。Laravel 项目里可以这样写:

class MerchantScope implements Scope { public function apply(Builder $builder, Model $model) { // 所有查询自动追加 merchant_id 条件,杜绝业务层漏写 $builder->where($model->getTable().'.merchant_id', merchant_auth_id()); } } class StockBalance extends Model { protected $table = 'stock_balance'; protected static function booted(): void { // 查询自动隔离:每个 Eloquent 查询都会带上当前商户ID static::addGlobalScope(new MerchantScope()); // 写入兜底:即使手动 new 模型漏赋值,也强制写入商户ID static::creating(function ($model) { if (empty($model->merchant_id)) { $model->merchant_id = merchant_auth_id(); } }); } }

逻辑说明:apply 方法在每次 Eloquent 查询时都会执行,相当于给所有查询语句自动追加 AND stock_balance.merchant_id = 当前商户ID。creating 事件兜底,防止开发时手动创建模型忘记赋值。merchant_auth_id() 是取当前登录商户 ID 的辅助函数,不同源码里可能是 session、JWT 或 Redis 里的登录态,实现位置不一样但目的相同。

参数说明:表名必须带前缀拼接,否则联表查询时字段会因同名冲突被覆盖;全局 Scope 只作用于 Eloquent,如果项目里还有查询构造器或原生 SQL 的模块,需要单独在那些地方补租户条件,这是排查串数据时优先怀疑的位置。merchant_id 字段要在所有核心业务表上加索引,尤其是和 warehouse_id、sku_id 组合的联合索引,否则多商户数据量上来后分页查询会明显变慢。

选共享表方案的另一个理由是备份和恢复方便:一套库就是所有商户的数据,配合 binlog 可以整库回放,不用逐商户处理。那种"每个商户一个独立库"的方案一般只留给报价很高的大客户,用一套代码加一个 database 字段做路由。对多数想跑 SaaS 营销版的团队来说,共享表加全局 Scope 已经是投入产出比最高的做法,独立库方案在小商户场景下就是给自己制造运维负担。

2.2 多仓库库存维度:仓库、批次和货位怎么建模

多仓库与单仓库的本质差别在于,商品的库存不再是一个单一数字。同一个 SKU 在总仓有 300 件、在门店仓有 50 件,它们不能简单相加当成一个可售库存,因为订单可能只从指定仓库发货。看一套进销存源码的成熟度,先看它有没有把库存表做成"商户 + 仓库 + SKU + 批次"四个维度。

典型的多仓库库存表设计是这样的:

CREATE TABLE `stock_balance` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `merchant_id` int NOT NULL COMMENT '租户ID', `warehouse_id` int NOT NULL COMMENT '仓库ID', `sku_id` int NOT NULL COMMENT '商品SKU ID', `batch_no` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号,无批次商品填空串', `qty` decimal(15,2) NOT NULL DEFAULT 0 COMMENT '可用库存', `locked_qty` decimal(15,2) NOT NULL DEFAULT 0 COMMENT '锁定库存', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_merchant_wh_sku_batch` (`merchant_id`,`warehouse_id`,`sku_id`,`batch_no`), KEY `idx_wh_sku` (`warehouse_id`,`sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

说明:qty 指可销售、可发出的库存;locked_qty 是订单占用但未发货的数量。这两个字段分开是避免在途订单把可售库存算错的关键。唯一索引 uk_merchant_wh_sku_batch 保证了同一个仓库同一个 SKU 同一个批次只有一行记录,这是防止库存重复累加的最后一道闸。批次号对食品、化工这类有保质期要求的行业是刚需,对普通标品商家可以不做批次,逻辑上用空串兜底即可。

把 locked_qty 单独拿出来说,因为这是最容易翻车的地方。很多源码把锁定量放在订单表里,用订单明细行数去减可用库存,等于每次可用库存都要实时算订单表,数据一多就慢,而且订单取消、拒收时会丢锁定量。更稳妥的做法是上面这样,锁定量的增加和释放都直接更新 stock_balance 一行的两个字段,保持可售库存与锁定库存即时可见。批次还会顺带解决成本核算问题:同一商品不同批次进价不同,先进先出还是移动平均,按批次就能算清楚,这也是进销存 ERP 里成本法在库存模块的落地方案。

如果仓库内部还分货位,可以在 stock_balance 上再扩一个 location_code 字段解决,比如 A 区 03 排这类编码。但货位会让索引膨胀,点数和盘点都会变复杂。我一般建议源码里保留货位字段,但默认不启用,等客户真有大型仓再说。对多数商贸批发客户,做到仓库和批次两层已经够用,硬上货位反而拖慢日常开单。

2.3 入库单出库单如何把库存写对:先锁行再改数

模型定好后,真正的难点在业务动作:入库单审核通过要加库存,出库单审核要减库存,盘点单差异要调整库存。这些动作如果先查再改,或者更新完不记录流水,后面排库存差异时就只能靠猜。我一般会把库存变动拆成三步:锁定库存行、写库存流水、更新余额。

-- 锁行并原子更新库存余额,避免并发下库存变成负数 UPDATE stock_balance SET qty = qty - 2, locked_qty = locked_qty - 2 WHERE merchant_id = 1003 AND warehouse_id = 5 AND sku_id = 88 AND batch_no = '' AND qty >= 2 AND locked_qty >= 2; -- 受影响行数为 1 才允许继续生成出库单流水

逻辑说明:这段 SQL 的关键在于 WHERE 条件里直接带 qty >= 2 和 locked_qty >= 2,这是把库存校验和扣减放进同一条语句,属于数据库层面的原子操作,比"先查再减"可靠得多。应用层只需要检查 UPDATE 影响的行数是否为 1:为 0 就说明库存不足或锁定量不够,终止出库并且提示错误。受影响行数为 1 之后,再向 stock_flow 表插入一条出库流水,这样库存余额和流水永远是对应的。

参数说明:这里的减法是严格等量扣减,即扣减前锁定多少,扣减时就减多少。如果业务允许在出库时多扣或者换批次,需要在单据行上扩展一个批次调整逻辑,不能直接改这段。注意 decimal 类型在严格模式下做比较和运算没有问题,但如果字段是 float,精度误差会让 qty >= 2 的判断在边缘值上产生玄学差异,建表时务必用 decimal(15,2) 而不是 float。

库存流水是事后排查的后悔药。每一笔入库、出库、盘点差异、调整单都要写一条流水,流水里至少包含来源单号、动作类型、变更前后数量、操作人。时间久了库存对不上,第一步就是拿流水和余额做差,差的不是零就说明有单据没走公共库存服务,直接查看是哪个模块绕过了它。这个习惯能让二次开发时少踩很多坑。

3. 扫码出入库链路:把扫码枪和手机相机接到 ERP 单据上

3.1 三种扫码设备的接入方式:USB 键盘枪、蓝牙枪、手机 App

扫码功能在进销存项目里不是自动化,而是输入方式的改造。出库员拿扫码枪扫一箱货,和用键盘敲 13 位条码,本质都是在输入一串字符,差别在于速度和出错率。USB 扫码枪最常见的模式是模拟键盘,也就是把扫到的条码转换成按键事件,快速输入到当前焦点输入框里,结尾默认带一个回车。这种模式在浏览器里表现最好,因为不需要装任何驱动,但也决定了一个坑:页面里不能同时存在两个可聚焦的输入框,否则回车会触发意外的表单提交,扫描结果会被弹窗吞掉。

蓝牙扫码枪本质还是 HID 键盘设备,只是把线换成了蓝牙,接入方式类似,但要注意配对后电脑输入法是否切换到了中文状态。手机端则不同,App 或小程序里没法模拟键盘,通常用摄像头扫码或调用系统扫码组件,识别成功后把条码作为参数直接请求后端接口。所以做扫码链路时,我一般要求后端把条码查询做成一个独立接口,让 USB 枪、蓝牙枪、手机摄像头三种入口都指向同一个查询服务,而不是在页面里堆针对不同设备的判断逻辑。

另外别忘了扫码链路还有一个上游环节:条码标签打印。很多客户会买热敏打印机自己打标,源码得支持自定义标签模板,至少把商品条码、箱码、规格、价格这几个字段做成可拖拽配置。否则标签上的编码和数据库对不上,扫码枪扫出来永远是"未找到商品",现场的人只会觉得系统是坏的,退货率直接上升。

3.2 扫码解析接口:从条码到商品行,前端只做回填

扫码之后第一件事是把条码解析成商品。现实中的条码并不总是标准 EAN-13,很多批发客户的货品用的是内部编码、箱码甚至带特殊前缀的条码。后端接口要做的事是依次尝试精确匹配、字段匹配和前缀匹配,而不是只查一个 barcode 字段。

// 扫码解析接口:入参只有一个 barcode,返回商品信息与可用库存 public function scan(Request $request) { $barcode = trim($request->input('barcode')); // 1. 精确匹配:商品条码、内部编码、箱码 $goods = Goods::query() ->where('barcode', $barcode) ->orWhere('goods_sn', $barcode) ->orWhere('box_barcode', $barcode) ->first(); // 2. 前缀匹配:兼容条码打印机缺位和批量段码 if (!$goods) { $goods = Goods::query() ->where('barcode', 'like', $barcode . '%') ->orderByRaw('CHAR_LENGTH(barcode) ASC') ->first(); } // 3. 命中后把默认售价和仓库可用库存一并返回 if (!$goods) { return response()->json(['code' => 1, 'msg' => '未找到商品']); } return response()->json([ 'code' => 0, 'data' => [ 'sku_id' => $goods->id, 'name' => $goods->name, 'spec' => $goods->spec, 'unit' => $goods->unit, 'sale_price' => $goods->sale_price, // 默认售价,下单时可改 'stock' => StockService::available( $goods->id, $request->input('warehouse_id', 0) ), ], ]); }

逻辑说明:代码把三个匹配规则串成一条链路,精确匹配没命中才走前缀匹配。前缀匹配在条码打印机缺位、条码贴错但前几位还正确时能救命,但不能作为唯一方式,否则扫一个不存在的条码也会匹配出近似商品。StockService::available 取的是当前指定仓库的可用库存数,warehouse_id 参数由客户端传入,没传时默认查全部仓库的合计。

参数说明:orderByRaw('CHAR_LENGTH(barcode) ASC') 保证前缀匹配时优先返回条码最短的商品,也就是最接近完整编码的那一个。这个排序条件在 MySQL 里写法稳定,但不建议把前缀匹配做成 like '%前缀%' 双百分号,那样会扫出所有含该子串的商品,误扫概率极高。前端拿到返回值后把 sku_id、数量、售价填入单据行,价格允许手工改,库存仅供展示和出库校验。入库和出库的扫码可以共用一个解析接口,区别只在后续业务动作不同。

3.3 出库时的扫码校验:扫错货和漏出库要在现场拦住

扫码出库的第二步是校验。仓库里最常见的翻车场景是:订单要发 A 商品,扫码枪扫了 B 商品,系统如果只做查询不回提示,那这单就错发出去了。出库校验逻辑应该是逐行核对条码对应的 sku_id 和当前单据行是否一致,不一致时给出声音和颜色提示,而且该行不允许继续。

// 出库单逐行扫码校验:扫到的条码必须和当前出库行一致才能过 public function checkOutItem(ScanRequest $request) { $item = OrderItem::query() ->where('id', $request->input('item_id')) ->where('order_id', $request->input('order_id')) ->lockForUpdate() ->first(); $scannedGoods = Goods::where('barcode', $request->input('barcode'))->first(); if (!$scannedGoods || $scannedGoods->id != $item->sku_id) { return response()->json(['code' => 1, 'msg' => '条码与当前订单行不匹配']); } // 累计扫码数小于应发数才允许继续 $scannedQty = ScanLog::where('order_id', $item->order_id) ->where('item_id', $item->id) ->sum('qty'); if ($scannedQty + $request->input('qty') > $item->qty) { return response()->json(['code' => 1, 'msg' => '扫码数量超过应发数']); } ScanLog::create([ 'merchant_id' => $item->merchant_id, 'order_id' => $item->order_id, 'item_id' => $item->id, 'sku_id' => $item->sku_id, 'qty' => $request->input('qty'), 'operator_id' => auth('staff')->id(), ]); return response()->json(['code' => 0, 'msg' => '校验通过']); }

这段代码把两个最关键的限制放到了后端:条码必须匹配、累计扫码量不能超过应发量。lockForUpdate() 给订单行加了行锁,避免两个扫码终端同时操作同一行导致超扫。ScanLog 表记录每一次扫码动作,这是事后追溯错发、漏发的基本凭证。实际项目里还应该在前端做声音反馈,扫码枪扫对了一声"滴",扫错了连续两声,这样仓库工人不用盯屏幕也知道结果。声音反馈的实现在前端,但触发逻辑完全依赖后端这个接口的 code 字段。

这条链路跑通后,扫码出入库就从一个功能点变成了完整业务闭环:扫码枪输入、后端解析商品、系统校验订单行、记录扫码日志、库存按单据扣减。多数号称支持扫码的进销存 ERP 源码只做了第一步解析,后面的校验和流水往往缺失,这也是实际使用中扫码功能被工人弃用的主要原因。所以评估这套源码时,建议重点看这个环节:是否有 ScanLog 表、出库单是否强制逐行扫码校验、扫码错误时是否拦截出库。

4. SaaS 营销版怎么不把库存玩崩:活动库存池与价格快照

4.1 活动库存池与可售库存为什么要分开

SaaS 营销版的常见玩法是满减、优惠券、秒杀和拼团。这些玩法和普通销售在库存上有一个本质区别:活动期间是集中在短时间内消耗库存,而且活动承诺的库存如果被普通订单占用了,活动开始时才发现库存不够,就会引发客诉。所以成熟做法是把活动库存独立成一个池子,活动商品先往池子里预存一定数量,活动订单走池子扣减,普通订单继续走可售库存,两个池子互不干扰。

活动库存池建池时,要记录来源:是从某个仓库的可售库存转过来的,还是新采购单独入库。转过来的常见做法是把 stock_balance 里的 qty 拆出一部分到活动库存表,活动结束后再回补。做这个拆分时最怕的是同时操作两张表没有事务,导致活动池加了、可售池没减,于是两边都对不上。我一般会把"创建活动到划拨库存"放进一个数据库事务里,任何一步失败都整体回滚。

DB::transaction(function () use ($promo, $sku, $qty) { // 从可售库存划转一部分到活动库存池,decrement 是原子更新 StockBalance::where('merchant_id', $promo->merchant_id) ->where('warehouse_id', $promo->warehouse_id) ->where('sku_id', $sku->id) ->where('qty', '>=', $qty) ->decrement('qty', $qty); // 活动库存池记录剩余数量,活动结束回补时以这里为准 PromoStock::create([ 'merchant_id' => $promo->merchant_id, 'promo_id' => $promo->id, 'sku_id' => $sku->id, 'qty' => $qty, 'remain_qty' => $qty, ]); });

逻辑说明:第一个 decrement 只在库存充足时更新成功,第二个 create 在同一个事务里,任何一个失败都会回滚。这样活动库存池和可售库存的划拨是强一致的,不会出现活动开始了但可售库存没减的情况。活动结束时,把 remain_qty 剩余的数量加回 stock_balance,再删除 PromoStock 记录。

参数说明:划拨数量要在创建活动表单里校验,不能超过当前仓库可用库存。如果活动配置了多个仓库,需要按仓库逐条执行上述事务,而不是只划拨一个默认仓。这里还用到一个细节:PromoStock 里存的是原始划拨量 qty 和剩余量 remain_qty,对账时可以直接算出活动消耗量,方便财务核对活动成本。

4.2 用 Redis 原子操作扣活动库存:秒杀不再超卖

活动库存的扣减和普通出库不一样,因为它面向高并发。秒杀场景下一瞬间可能进来几千个请求,如果用 UPDATE 数据库行再加锁,行锁竞争会让请求排队,数据库压力很大。常见做法是把活动库存预热到 Redis,用 Lua 脚本原子扣减。

// 秒杀下单扣活动库存:Lua 脚本在 Redis 端原子执行 public function deductPromoStock($merchantId, $skuId, $warehouseId, $qty) { $key = "promo:stock:{$merchantId}:{$warehouseId}:{$skuId}"; $lua = <<<LUA local remain = redis.call('get', KEYS[1]) if not remain then return -2 -- 活动库存未预热 end remain = tonumber(remain) if remain - tonumber(ARGV[1]) < 0 then return -1 -- 库存不足 end redis.call('decrby', KEYS[1], ARGV[1]) return remain - tonumber(ARGV[1]) LUA; // 传参顺序以所用 Redis 客户端文档为准:KEYS[1] 是库存 key,ARGV[1] 是扣减数量 $result = Redis::eval($lua, 1, $key, $qty); if ((int)$result < 0) { throw new InsufficientStockException('活动库存不足或未预热'); } return $result; }

逻辑说明:Lua 脚本在 Redis 端执行时是原子的,get 和 decrby 之间不会插入其他命令,所以并发下不会出现两个请求同时判定库存大于零然后都扣成负数的情况。返回 -1 表示库存不足,-2 表示活动库存没提前写入 Redis。活动创建或编辑时要执行一次商品活动库存预热,把数据库 PromoStock 里的剩余数量同步到 Redis key,活动结束或库存归零后清理 key。

参数说明:key 的命名把 merchant_id、warehouse_id、sku_id 全部带上,避免跨商户、跨仓库扣错库存。预热时还可以给 key 设置一个过期时间,比如活动结束后自动失效,避免 Redis 里留一堆无用 key 占用内存。这个 Lua 方案在并发量几万的场次里够用,如果活动规模再大一层,需要在前面加本地令牌桶限流,控制真正打到 Redis 的请求量,避免把 Redis 的 CPU 打满。

4.3 价格快照:订单行锁死下单时的促销规则

营销活动另一个容易翻车的地方是价格。下单时商品参与满 299 减 50,活动结束或者运营改了活动设置后,这个订单的金额不能跟着变。不然会出现订单已付款、后台改规则、财务对账金额对不上。解决办法是在订单行里保存价格快照,至少包含商品单价、优惠规则标识、优惠金额和规则快照 JSON。

// 订单行保存价格快照:活动规则变化不影响已支付订单 $orderItem->unit_price = $sku->sale_price; // 商品原价 $orderItem->promo_scheme = $couponRule->rule_snapshot; // 规则快照 JSON $orderItem->pay_price = $calcPrice; // 实付单价 // 对账时只比对这两个字段,规则表改成什么样都不用管 $orderItem->save();

这段代码里 rule_snapshot 是活动规则的一张只读副本,包含满减门槛、折扣比例、可叠加的商品范围。对账或售后计算退款时,只读这个快照,不回查活动规则表。这个习惯能省掉大量售后扯皮,因为规则表可能已经被运营改了十次,而订单快照始终是下单那一刻的事实。

参数说明:快照 JSON 不宜存运营备注这类非结构化字段,只存影响金额计算的结构化规则。退款计算时常见做法是用快照里的 pay_price 和 rule_snapshot 做原路比例退还,而不是重新实时算一次活动价。对 SaaS 服务商来说,价格快照还能用来做商户结算:一个商户的订单汇总、平台抽佣,都以快照字段为准。很多进销存 ERP 源码只做了商品当前价,没有订单行快照,这类源码做 SaaS 营销版时会出现"订单金额和结算单对不上"的经典问题。

4.4 营销规则也要过租户隔离:优惠券不能跨商户领

营销模块的隔离比库存更容易被忽略。优惠券表、满减规则表、拼团活动表同样要挂 merchant_id,并且要挂全局 Scope。最容易被漏掉的是券码唯一性:如果 A 商户生成的券码是 ABC123,B 商户也能生成一个 ABC123,用户输错商户入口就可能把 A 商户的券用到 B 商户的订单上,核销逻辑一旦没校验商户就会资损。

// 券码生成:商户前缀 + 随机串,避免跨商户撞码 $code = $merchantId . '-' . strtoupper(Str::random(8));

说明:给券码加商户前缀是成本最低的防呆方案,哪怕两个商户生成了相同随机串,前缀也会把它们区分开。核销时后端一定要校验 code 前缀和当前订单的 merchant_id 是否一致,不一致直接拒绝。营销规则表的数据隔离建议和库存表用同一套 MerchantScope 机制,这样新增营销功能时不会漏加租户条件。

5. 源码部署与二次开发的避坑清单:五个翻车点别等上线才遇到

源码包的部署环境和二次开发水平参差不齐,以下五个问题是我接触这类多商户多仓库带扫描进销存项目时反复遇到的高频翻车点,按现象、原因、解决的顺序写,可以直接对照排查。

现象 1:登录后串商户、能查到别的商户的订单和客户。

原因:多商户 SaaS 源码通常用 Redis 保存登录态和商户上下文。如果 Redis 的 key 没有按商户区分前缀,比如登录 token 只存了 staff_id,两个商户的 staff_id 恰好相同,就会命中同一条 session,这是共享存储方案最经典的串数据路径。

解决:把登录态 key 统一改成 merchant:{merchant_id}:staff:{staff_id}:token 这样的层级结构,同时检查 auth guard 写入 session 时是否显式带了 merchant_id。验证方法是用两个商户分别登录后,检查 Redis 里是否存在跨商户同 key 的记录:

# 查看当前登录态 key 的分布,确认是否按商户隔离 redis-cli --scan --pattern "auth:*" | head -50

正常情况应该看到每个 key 都包含不同商户 ID,如果出现只有 staff_id 没有 merchant_id 的 key,基本就是串数据的根源。

现象 2:扫码枪扫出来少一位数字,或者多出中文、前缀字符。

原因:USB 扫码枪模拟键盘时,如果系统当前输入法是中文状态,扫出的内容可能被输入法拦截或转换成全角字符,条码数字被吃掉。少一位有时是条码枪被设置成了添加前后缀的默认模式,或者 HID 报文超时导致断码。

解决:仓库电脑把默认输入法切到纯英文键盘,扫码枪配置里关闭厂商默认前缀,只保留回车后缀。如果是多国语言条码,检查条码枪的字符集设置和操作系统的区域设置保持一致。这类问题在仓库现场排查非常消耗耐心,所以部署时要把这些基础配置写进交付清单,不要等仓管员报障了再远程去猜。

现象 3:库存被扣成负数,两个仓管员同时出库同一商品时尤其明显。

原因:应用层先查库存再判断、后执行扣减,两个请求同时通过判断,然后各自执行扣减,就超卖了,也就是典型的非原子操作。

解决:按 2.3 的做法,把扣减和判断放进同一条 UPDATE 语句。如果项目已经大量使用先查后改的写法,临时补救是给库存余额更新统一加 Redis 分布式锁,锁 key 用 merchant_id:warehouse_id:sku_id:

# 用 Redis 分布式锁临时兜底,锁超时时间建议 2 秒 SET lock:stock:1003:5:88 1 NX EX 2

加锁成功后执行更新,执行完释放锁。这个方案能让请求稍等,但不会允许并发扣减,是保留旧代码结构时的低成本补丁。

现象 4:源码标题写着无限商户,部署到生产域名后提示授权失效,只能建一个商户。

原因:很多源码包里的授权验证并不限制数据库表数量,而是校验域名和授权码。本地或测试域名可以通过,换成生产域名后校验失败,看起来就像"商户数被限制"。

解决:部署时要先搞清楚授权校验的逻辑和位置,通常在中间件或引导文件里。如果要做商业交付,直接找源码提供商购买生产授权或者要求提供去掉域名校验的版本,并在这个授权校验失败的位置记录日志,方便快速定位。不要指望改 hosts 或者换端口绕过,很多源码的授权校验已经做了端口识别,临时方案上线后只会更难收场。

现象 5:备份恢复后流水号重复,单据号在测试库和生产库交叉撞车。

原因:MySQL 的 auto_increment 值在表重建或数据导入后,如果备份表里丢掉了部分高 ID,自增起点会落回已存在的区间,产生重复单据号。

解决:恢复备份后,对核心单据表手动修正自增起点,作为兜底手段:

-- 把自动编号起点修正到当前最大 ID + 100,留出安全余量 ALTER TABLE `order` AUTO_INCREMENT = 100001;

严谨一点的做法是导出备份时保留表结构中的 AUTO_INCREMENT 信息,不要用 --no-create-info 模式只导数据,否则所有自增表都可能回退。单据号业务上要保证全局唯一,更好的方案是改成"日期 + 商户 ID + 序号"的生成方式,少依赖数据库自增。

这些坑的共同特点是不会在部署当天爆发,而是跑到第三个月、数据量上来之后一起冒出来。所以源码装上之后,不要只测"能不能登录、能不能开单",要把租户隔离、扫码校验、库存并发这三条基线按上面说的逐项过一遍,发现实现方式不对就趁早上层改掉,不要拖到客户数据真出问题再救火。

6. 上线前用三个商户三条仓库压一遍:验证清单和一个压箱底的技巧

拿到这套带扫描的多商户多仓库云进销存源码,上线前我习惯做一组三商户三仓库的压测。每个商户建三个仓库,用两个扫码终端同时操作,验证四件事:跨商户是否串数据、扫码出库是否拦截错误条码、活动库存是否超卖、对账是否对平。这四项如果都过,这套源码才算接住了多商户 SaaS 进销存的核心场景。验证时注意给每个商户准备独立的商品和库存数据,不要复用同一批 SKU,否则仓库维度上的校验会形同虚设。

一个压箱底的技巧是先把盘点单跑通。多仓库系统上线半年后最常遇到的问题就是库存账实不符,这时候补录单据很痛苦,但盘点单可以把差异一次性转成调整单据。做法是每个仓库每月做一次抽盘,盘点的差异自动生成一张盘盈盘亏单,审核后写入库存流水,账实就重新对齐了。这套流程在进销存系统里属于最容易被忽略却最刚需的功能,评估源码时可以先看它是否支持盘点差异生成出入库单据,如果不支持,二次开发时优先补上。

最后说一个我的习惯:正式上线前把仓库电脑的输入法、扫码枪配置、打印模板都录成一条完整的操作录像,让仓管员照着走一遍真实订单流程。这一步能提前暴露单据打印缺字段、扫码枪配置乱码这类现场问题,比上线后让仓管员在群里喊"又出问题了"强得多。这套源码如果能在订单、库存、扫码、营销四层都通过上面这些验证,再结合你自己的盘点流程和价格快照机制,是可以放心接真实业务的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询