简介:一套基于 PHP 的仓储后台管理系统源码与数据库打包资源,面向 PHP 开发者及需要快速搭建库存管理系统的团队,聚焦仓库货品出入库操作与库存查询场景,基于 MySQL + CodeIgniter + jQueryUI 架构实现。压缩包共 1602 个文件,包含 262 个 PHP 业务脚本、522 个 HTML 页面、381 个 CSS 样式、257 张 PNG 图标及 JS、SQL、配置文件等,整体大小仅 4.09MB,部署轻量。包内附完整 wms 数据库 SQL 文件,预设管理员账号 admin/admin,安装时按注释修改 application/config/database.php 和 config.php 中的 base_url 即可运行。通过源码可学习 CI 框架的 MVC 分层、数据库操作、jQueryUI 界面组件及库存模块的数据表设计思路,适合作为课程设计或入门项目参考。当前已有 950 人学习下载。
1. 基于PHP的仓储后台管理系统,小仓库最务实的落地方案
一个五十个 SKU、日均两三百单出入库的小仓库,买商业 WMS 一年大几千还绑定操作习惯,引 ERP 又明显过重。很多做电商、做加工配套的团队最后的做法,是用 PHP 自己搭一套仓储后台管理系统,源码和数据库一起交付,接口、报表、权限都能自己改。
开发成本基本集中在一件事上:把单据流和库存账算准。下面按表结构、事务代码、部署配置再到对账技巧,把一个线上可跑的方案完整讲一遍。适合刚接手 PHP 项目的人,也适合想把现有系统库存部分重写一遍的熟手。
2. 仓储后台的数据模型:先把单据流和库存账拆清楚
2.1 把业务动作收敛成核心表
仓储后台管理系统在中小团队里最常见的形态是「一张出库单、一张入库单、一个库存数字」。再复杂的要求,比如批次、效期、多仓、条码,都从这三点延伸。我一般把表设计收敛成这样,避免一上来铺开几十张表,结果单据和库存对不上账:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| wms_product | 商品主数据 | pid, sku, name, spec, unit |
| wms_warehouse | 仓库与货位 | wid, name, location_code |
| wms_inbound | 入库单头 | order_no, type, status, operator |
| wms_inbound_item | 入库明细 | order_id, product_id, qty |
| wms_outbound | 出库单头 | order_no, type, status, receiver |
| wms_outbound_item | 出库明细 | order_id, product_id, qty |
| wms_inventory | 实时库存 | warehouse_id, location_id, product_id, qty |
| wms_stock_log | 库存流水 | type, before_qty, after_qty, change_qty, ref_order_no |
注意这是「两对单头 + 明细」的经典结构,目的是一张单子支持多种货物,明细表靠order_id关联,不冗余存仓库信息,仓库只待在单头上。这样「整单改仓」就只需要 update 单头一行。很多从 Excel 迁移过来的需求,第一版就毁在把每种货做成一个字段,比如product1_qty、product2_qty,后续加一个 SKU 就要改表结构,这条路千万别走。
2.2 库存表为什么按「一仓一货位一商品」一行来设计
初学阶段容易把库存做成「商品一个数字」:在 product 表里放stock字段。这在单仓、无货位的小卖部场景勉强能跑,一旦出现同仓两个货位、同一 SKU 分两次到货,就彻底失控了。库存表必须按维度拆行,联合唯一键是这个设计的地基:
CREATE TABLE wms_inventory ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, warehouse_id INT UNSIGNED NOT NULL COMMENT '仓库ID', location_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '货位ID, 0表示无货位', product_id INT UNSIGNED NOT NULL COMMENT '商品ID', qty INT NOT NULL DEFAULT 0 COMMENT '可用库存', frozen_qty INT NOT NULL DEFAULT 0 COMMENT '锁定库存, 出库预占', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', update_time DATETIME NOT NULL, UNIQUE KEY uk_wh_loc_pro (warehouse_id, location_id, product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;联合唯一键uk_wh_loc_pro保证同一维度不会出现两行,入库时用ON DUPLICATE KEY UPDATE就能天然累加。qty用 INT 而不用 DECIMAL,是因为大部分仓储计量单位是件、箱,整数加减没有浮点误差;如果做的是公斤、米这种计量,就改成DECIMAL(14,3),同时所有代码统一按 DECIMAL 传参,不能用字符串拼接。frozen_qty是给「先下单后出库」用的预占库存,电商场景很常见,纯内部调拨可以不用。
注意:
wms_inventory是「结果」,wms_stock_log是「过程」。任何对库存表的写操作都必须同步写一条流水,否则盘不出差异时没有任何线索。
流水表最忌只记一个change_qty,最好把变动前后的值都记下来。出了账实差异,靠这三个字段能直接推算每一步的因果链,而不是对着订单编号猜。
2.3 单据与库存的入账时机:先建单、再入账,禁止「保存即扣」
很多半路出家的后台系统把库存变更直接写在「新增出库单」接口里:保存单据的同时 update 库存。隐患在于,单据一旦被中途修改或删除,库存已经动过了,只能靠人工再补一笔,时间一长必然对不上。
正确时序是:单据先落库为「草稿」状态,操作员确认无误后点「审核」,审核动作才在一个事务里完成三件事——更新单据状态、变更库存、写流水。事务边界必须包住这三个动作,任何一个失败就整体回滚。单据状态固定用三态:draft(草稿)、done(已入账)、cancel(已作废)。不允许存在「已审核但库存没动」的状态,审核就是入账触发器。反审核(红冲)另做一张负向单据,而不是直接去改库存,审计链路才能完整。
3. 用 PHP 把入库出库跑成事务:PDO 代码与行锁
3.1 PDO 连接参数这样配,事务才靠得住
仓储后台管理系统用原生 PHP 还是框架?我的看法是:核心业务几十个文件以内,原生 PHP + PDO 比强行套 Laravel 更好维护;如果你已经用了 ThinkPHP 之类的框架,底层同样绕不开 PDO 这套连接参数。连接配置是最容易被忽略的一层,很多「莫名其妙丢事务」的 bug 都出在这里:
<?php $dsn = 'mysql:host=127.0.0.1;port=3306;dbname=wms;charset=utf8mb4'; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_PERSISTENT => false, ]; $pdo = new PDO($dsn, 'wms_user', 'wms_pass', $options);四个参数各有讲究。ERRMODE_EXCEPTION让 SQL 错误直接抛异常,配合事务才能做到出错即回滚;EMULATE_PREPARES设为 false,让 MySQL 服务端做预处理,避免占位符被本地转义后类型混乱,where 条件里传数字字符串也不容易踩索引失效。PERSISTENT关掉长连接,PHP-FPM 下长连接会和连接池抢占 MySQL 连接数,流量稍大就把max_connections打满。charset=utf8mb4直接写在 DSN 里,比后执行的SET NAMES更早生效,从源头避免中文乱码。
3.2 入库接口:库存累加与流水写入同一事务
入库的核心代码可以这样写。假设$order已包含仓库和货位信息,$items是明细数组,$pdo是上面的连接实例:
<?php try { $pdo->beginTransaction(); foreach ($items as $it) { // 1. 先锁库存行, 拿变动前的值, 这一行不存在时 FOR UPDATE 不阻塞 $stmt = $pdo->prepare( 'SELECT qty FROM wms_inventory WHERE warehouse_id = ? AND location_id = ? AND product_id = ? FOR UPDATE' ); $stmt->execute([$order['warehouse_id'], $it['location_id'], $it['product_id']]); $row = $stmt->fetch(); $beforeQty = $row ? (int)$row['qty'] : 0; // 2. 存在就累加, 不存在就插入; 唯一键是并发的最后防线 $sql = 'INSERT INTO wms_inventory (warehouse_id, location_id, product_id, qty, update_time) VALUES (?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty), update_time = NOW()'; $pdo->prepare($sql)->execute([ $order['warehouse_id'], $it['location_id'], $it['product_id'], $it['qty'] ]); // 3. 写流水, 保留变动前后值和差额 $logSql = 'INSERT INTO wms_stock_log (product_id, type, ref_order_no, before_qty, after_qty, change_qty, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())'; $pdo->prepare($logSql)->execute([ $it['product_id'], 'inbound', $order['order_no'], $beforeQty, $beforeQty + $it['qty'], $it['qty'] ]); } // 4. 同一事务里把单据置为已入账 $pdo->prepare('UPDATE wms_inbound SET status = "done", audit_time = NOW() WHERE id = ?') ->execute([$order['id']]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); error_log($e->getMessage()); // 返回前端: 入账失败, 单据保持 draft 状态 }ON DUPLICATE KEY UPDATE把「判断存在、决定插入还是更新」合并成一个原子操作,省掉一次 SELECT 往返和一段锁等待窗口。MySQL 8.0.20 之后VALUES()语法已标记废弃,可以写成INSERT ... AS new ON DUPLICATE KEY UPDATE qty = qty + new.qty,语义完全等价。before_qty用FOR UPDATE查出来而不是靠影响行数推断,流水里永远有据可查。最后更新单据状态也在同一事务里,明细任何一条失败,整张单都不会变成 done。
3.3 出库接口、负库存校验与行锁的边界
出库比入库多一步:先确认库存够不够。常见错误是「先 SELECT 判断 qty 够,再 UPDATE」,两个请求并发时判断结果互相覆盖,于是出现负库存。解决方法是把判断和扣减放进同一行锁保护下:
<?php $pdo->beginTransaction(); // 按唯一键取行, FOR UPDATE 锁住这一行直到事务结束 $sql = 'SELECT qty FROM wms_inventory WHERE warehouse_id = ? AND location_id = ? AND product_id = ? FOR UPDATE'; $stmt = $pdo->prepare($sql); $stmt->execute([$order['warehouse_id'], $it['location_id'], $it['product_id']]); $row = $stmt->fetch(); if (!$row || $row['qty'] < $it['qty']) { $pdo->rollBack(); throw new RuntimeException('库存不足: product_id=' . $it['product_id']); } $pdo->prepare('UPDATE wms_inventory SET qty = qty - ?, update_time = NOW() WHERE id = ?') ->execute([$it['qty'], $row['id']]); $pdo->prepare('INSERT INTO wms_stock_log (product_id, type, ref_order_no, before_qty, after_qty, change_qty, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())') ->execute([$it['product_id'], 'outbound', $order['order_no'], $row['qty'], $row['qty'] - $it['qty'], -$it['qty']]); $pdo->commit();FOR UPDATE锁的是 InnoDB 索引记录,范围精确到唯一键对应的那一行,不会锁全表。但有两个边界必须知道。一是锁在事务提交或回滚时才释放,事务里别做远程调用、别 sleep,否则并发一高全是Lock wait timeout exceeded。二是明细有多个商品时,必须先按 product_id 排序再逐行处理,否则两个出库单各持一行锁、又互相等对方那一行,就会死锁;MySQL 检测到死锁会回滚一方,表现是「偶尔一条单子报错」,排序之后这类问题基本绝迹。
frozen_qty预占流程可以在这段代码上扩展:下单时只扣 frozen 不动 qty,出库时把 frozen 转成实际扣减,公式是qty = qty - 1, frozen_qty = frozen_qty - 1,保证「锁定库存 + 可用库存」恒等于总库存。这个公式在并发下不会出现超卖,因为两行更新都在同一事务里。
4. 把系统跑起来:数据库初始化、PHP 部署与三个必调参数
4.1 初始数据库:把数据模型落到 MySQL
拿到「源码+数据库」压缩包之后,最常见的第一个动作是导入数据库。我不会直接双击导入现成 dump,而是先跑一遍建表脚本,确认表结构跟自己业务对得上,再决定要不要用自带的种子数据。下面是裁剪到最小可跑的初始化 SQL:
CREATE DATABASE IF NOT EXISTS wms DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE wms; CREATE TABLE wms_product ( pid INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, sku VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, spec VARCHAR(64) DEFAULT '', unit VARCHAR(8) NOT NULL DEFAULT '件', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; INSERT INTO wms_product (sku, name, unit) VALUES ('SKU001', 'A4复印纸', '箱'), ('SKU002', '签字笔黑色', '盒'); -- 服务端时区统一为东八区, 避免 NOW() 和 PHP date() 各差 8 小时 SET time_zone = '+08:00';utf8mb4_unicode_ci下,sku 唯一索引对英文大小写不敏感;业务上要区分大小时改成utf8mb4_bin。种子数据只插商品主数据,库存从零开始由入库单产生。不要图省事直接往wms_inventory里塞初始值,否则流水缺失,后续盘点永远对不平。数据库账号要给独立用户,走wms_user@'127.0.0.1'限定来源,root 只留本地 socket 登录。
4.2 PHP 版本与运行环境:nginx + php-fpm 最小配置
仓储后台管理系统跑在 LNMP 环境最省心。PHP 版本建议 7.4 到 8.2 之间,8.0 以上对类型声明和性能的提升明显;老项目若还在 PHP 5.x,mysql_*函数在 7.0 后已被移除,任何「源码包直接跑不起来」的问题八成出在这。nginx 站点配置只需保证 PHP 请求正确转发到 php-fpm:
server { listen 80; server_name wms.example.com; root /var/www/wms/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }try_files那行用来兼容不带 index.php 的伪静态路径,让入口集中到单一脚本;SCRIPT_FILENAME必须显式拼出真实路径,否则 php-fpm 返回空白页。压缩包如果带.user.ini或.htaccess,在 nginx 下不生效,需要把里面的upload_max_filesize、post_max_size搬到 php.ini。写入目录要给 php-fpm 运行用户,我统一chown -R www-data:www-data,不给 777。
4.3 三个必调参数:时区、内存、上传大小
| 参数 | 位置 | 推荐值 | 说明 |
|---|---|---|---|
date.timezone | php.ini | Asia/Shanghai | 配合 MySQLtime_zone='+08:00',两边 NOW() 才一致 |
memory_limit | php.ini | 128M,导出报表可临时调 512M | 仓储系统里最重的操作是导出商品明细,条目多时极易触顶 |
post_max_size/upload_max_filesize | php.ini | 32M / 20M | 批次导入的 CSV、装箱照片都走 POST 上传,默认 8M 太小 |
max_execution_time | php.ini | 30,CLI 脚本设 0 | 万行级导入不能走 HTTP 请求,拆成 CLI 任务执行 |
最后一行其实是场景切换。批量导入一万行商品或出库明细时,HTTP 30 秒上限几乎必挂。正确做法是把解析和入库拆成 CLI 脚本跑,每 500 行提交一个事务,出错只回滚当前批次,日志记到第几行,修好数据后断点续跑。这个习惯在仓储系统里比任何框架优化都值钱。
数据库连接数也顺手说一句:php-fpm 每个进程持一个连接,pm.max_children=50就意味着最多 50 个并发连接。出现Too many connections时先SHOW PROCESSLIST看是不是全是 Sleep,再决定调max_connections还是调pm.max_children,别一上来就改 MySQL。
5. 给后台加一个「库存对账」菜单:十分钟写出盘点差异查询
对账是仓储后台管理系统最值得先做的高级功能,逻辑简单、价值直观,一分钟内就能暴露「账实不符」的具体商品。思路是拿 wms_stock_log 流水按商品累计差额,再和 wms_inventory 当前值比对,有差异的就是丢失或漏记的节点:
SELECT t.product_id, SUM(t.change_qty) AS log_total, i.qty AS inv_qty, i.qty - SUM(t.change_qty) AS diff FROM wms_stock_log t LEFT JOIN wms_inventory i ON i.product_id = t.product_id WHERE t.create_time >= '2025-01-01 00:00:00' GROUP BY t.product_id, i.qty HAVING diff <> 0;这条 SQL 依赖流水表里冗余存了change_qty。SUM(change_qty)是从统计起点到现在的净变动量,i.qty是当前账面值,两者相减的diff如果不为 0,说明要么流水漏记、要么库存被直接改过、要么期初数本身不对。HAVING diff <> 0把所有异常商品一次列全,再 JOIN 商品表带出 sku 和名称,就是一个可以直接挂进后台菜单的差异页。上线初期每天凌晨用 cron 跑一遍,结果写入差异表,早上看表就行,不用人肉翻 Excel。
再往外扩一步是盘点单:仓库实盘数量录入后,系统自动算出每个 SKU 盈亏数,生成一张盘盈盘亏单,管理员审核通过后才真正调整库存,并补一条stocktake类型的流水。这样闭环下来,任何一笔库存变动都能追溯到单据或盘点单。仓储后台管理系统最怕的「库存悄悄变了没人知道」,到这个程度就彻底根治了。
本文还有配套的精品资源,点击获取