ARTICLE DETAIL

资讯详情

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

PHP多仓库进销存ERP:库存模型、事务实现与对账SQL实战

PHP多仓库进销存ERP:库存模型、事务实现与对账SQL实战 简介面向需要搭建轻量级进销存或ERP系统的开发者和中小企业这份PHP网页版多仓库管理源码包含商品、库存、往来单位、发票、员工等后台管理模块适合二次开发与学习。压缩包共1423个文件约20.67MB其中489个PHP文件构成业务逻辑198个JS负责前端交互42个CSS和151个PNG等支撑界面样式与图片资源并附带SQL数据库文件便于导入初始化。目前已有216人学习下载。资源随附安装说明包含数据库导入、配置database.php及后台默认账号等可快速部署体验同时大量目录和缓存文件保留了CI框架结构特征便于理解多仓库系统中分类、入库单、发票单等模块的数据表关联对需要参考典型PHP进销存流程、扩展仓储字段或改造权限体系的读者较有价值。1. 多仓库进销存的核心不在页面而在库存数据模型把单仓库升级成多仓库最容易犯的错误就是在仓库表里加个字段然后页面继续按原来的方式查询。实际上一套 PHP 进销存 ERP 能不能用不在于界面多完整而在于库存账是否和业务单据在同一个事务里闭环。采购入库、销售出库、调拨、盘点每一笔都改同一个库存数字任何一个环节绕过统一入口库存账就会和流水账脱离。这里围绕 PHP 源码形态的多仓库 ERP 管理系统讲清楚数据模型怎么拆、出入库和调拨用什么事务方案、Nginx 与 MySQL 部署时改哪些参数最后给一条能直接跑的对账 SQL用来找出仓库差异。适合正在做企业内网系统定制、接手老 ERP 源码以及想自己搭一套内部进销存的工程师。2. PHP 多仓库进销存的技术选型与 ERP 核心业务流程2.1 为什么 PHP 做中小 ERP 仍然值得选先回答一个经常被问的问题都 2025 年了为什么还在用 PHP 写进销存我的判断是看团队和场景。企业内网进销存通常并发很低几十个人同时开单已经算多的PHP-FPM 在这个量级下没有任何压力。PHP 源码的优势是交付快、改起来快、运维简单一台 2C4G 的服务器跑 Nginx、PHP、MySQL 就能支撑一个中型仓库的业务不需要引入 Java 那套构建链和部署链。提示如果你的系统以后可能要接电商中台日订单量上万再考虑把库存服务独立出来而不是一上来就上微服务。PHP 生态里做这类系统最常走的两条路线一条是基于 Laravel 或 ThinkPHP 这类框架重写好处是模型、迁移、队列都是现成的另一条是直接在老源码上改适合已有系统、不想动底层的场景。我一般会先看原项目有没有统一的库存操作入口没有的话再考虑重构也不迟。2.2 ERP 系统业务流程从采购单到库存流水进销存 ERP 不是“能录单”就行重点是流程闭环。最常见的一套业务流程是采购订单审核后生成入库单入库单确认后增加对应仓库的可用库存销售订单审核后生成出库单出库单确认后扣减仓库库存仓库之间调拨用调拨单源仓库先扣、目标仓库后加盘点产生盘盈盘亏也要通过库存流水调整账面数。这个流程里最关键的是“业务单据”和“库存变动”的关系。一般做法是业务单据审核通过后调用库存服务写入一张库存流水库存表只是流水的汇总结果。不要把库存直接写在商品表或单据明细表里否则每次统计都要去扫描一大堆业务表。2.3 多仓库管理系统的基础表关系骨架在动手写代码前先把表关系理清楚。多仓库 ERP 最少需要这几类数据商品档案SKU、仓库档案、库存表、库存流水表、业务单据主表、业务单据明细表、往来单位。其中商品和仓库是多对多商品在某个仓库的库存数量体现在库存表里而不是商品表里。先验证 PHP 能连上数据库再开始建模?php $dsn mysql:host127.0.0.1;port3306;dbnameerp;charsetutf8mb4; $options [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]; $pdo new PDO($dsn, erp_user, 用你自己的密码, $options);这段代码里ATTR_EMULATE_PREPARESfalse会走 MySQL 原生预处理参数绑定更安全避免字符串拼 SQL 的注入问题。连接成功后后面的库存操作都用这一个$pdo实例保证事务能跨多条 SQL 生效。多仓库和非多仓库的表结构差异可以看下表对比项单仓库版本多仓库版本商品表可能有库存字段不含库存字段库存表按商品一条记录按 仓库商品 一条记录业务单据不区分来源/目标仓库出库单带仓库ID调拨单带两个仓库ID库存流水按商品记录每条流水必须带仓库ID这个表看下来就明白多仓库真正的变化是“所有库存相关的表都多了一个仓库维度”。如果一开始不把这个维度写进主键和索引后面每个页面都得补 join。3. 多仓库库存表、流水表与单据表把仓库维度建进主键3.1 库存表warehouse_id 和 sku_id 联合唯一库存表是整套 ERP 的“账面数”来源。设计上最重要的是物理约束同一个 SKU 在同一个仓库只能有一行库存记录。要是允许重复行后面所有查询都得 group by还要处理历史脏数据没必要。建表 SQL 如下CREATE TABLE inventory ( id BIGINT UNSIGNED AUTO_INCREMENT, warehouse_id INT UNSIGNED NOT NULL COMMENT 仓库ID对应 warehouse.id, sku_id INT UNSIGNED NOT NULL COMMENT 商品SKU对应 product.id, qty INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 当前可用库存, locked_qty INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 已锁定库存, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_wh_sku (warehouse_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT各仓库即时库存;这里的uk_wh_sku唯一键就是多仓库系统最重要的约束。如果代码里误操作给同一个仓库重复插入同一个 SKU数据库会直接报错而不是让数据悄悄坏掉。locked_qty字段用于锁定库存比如销售订单审核后先锁定、出库时再扣减可以避免“两张单同时看到同一件货”的问题。3.2 库存流水表把每次变动变成一行记录库存表存的是结果流水表存的是过程。任何一笔库存变动都必须写一行流水记录变动前数量、变动后数量和业务单号。表结构如下CREATE TABLE inventory_log ( id BIGINT UNSIGNED AUTO_INCREMENT, warehouse_id INT UNSIGNED NOT NULL, sku_id INT UNSIGNED NOT NULL, change_qty INT NOT NULL COMMENT 正数表示入库负数表示出库, before_qty INT NOT NULL COMMENT 变动前库存数量, after_qty INT NOT NULL COMMENT 变动后库存数量, biz_type VARCHAR(32) NOT NULL COMMENT order_in/order_out/transfer_in/transfer_out/check, biz_no VARCHAR(64) NOT NULL COMMENT 业务单据号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_wh_sku_time (warehouse_id, sku_id, created_at), KEY idx_biz_no (biz_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水账;before_qty和after_qty一定要存下来。只存change_qty的话后人很难对账也不知道这条流水在它那个时间点看到的账面数是多少。idx_wh_sku_time这个复合索引直接服务于“某个仓库某个 SKU 按时间排流水”的页面idx_biz_no用于按单追查。3.3 单据主表与明细表别把业务数据塞进库存表入库单、出库单、调拨单其实可以共用一张主表和一张明细表用order_type区分类型。多仓库场景下主表需要区分from_warehouse_id和to_warehouse_id两个字段CREATE TABLE business_order ( id BIGINT UNSIGNED AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 单号业务上唯一, order_type TINYINT NOT NULL COMMENT 1采购入库 2销售出库 3调拨 4盘点, from_warehouse_id INT UNSIGNED DEFAULT NULL COMMENT 调拨/出库时使用, to_warehouse_id INT UNSIGNED DEFAULT NULL COMMENT 采购入库/调拨时使用, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已审核 2已完成, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT业务单据主表; CREATE TABLE business_order_item ( id BIGINT UNSIGNED AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, sku_id INT UNSIGNED NOT NULL, qty INT NOT NULL COMMENT 数量正数表示入库方向负数表示出库方向, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT业务单据明细;主表和明细分离后一张单据可以包含多个 SKU也方便做审核、打印、对账。细心的你可能发现了入库单和采购订单、出库单和销售订单其实是两层。业务上可以先有采购订单审核落库时再生成入库单库存只在“入库单确认”这一刻变化。分得越清后续加业务流程就越不会互相踩。多仓库系统的核心表大致可以这样对应表名职责多仓库相关字段inventory商品在当前仓库的账面库存warehouse_idinventory_log库存变动的流水账warehouse_id, biz_type, biz_nobusiness_order业务单据主表from_warehouse_id, to_warehouse_idbusiness_order_item单据中的商品明细通过 order_id 关联主表4. 出入库与调拨的 PHP 事务实现让库存变动只走一个入口4.1 统一库存入口changeStock 函数建好表之后最关键的不是把页面写出来而是保证所有库存变动只走同一个入口。我一般会在service层放一个changeStock方法入库传正数、出库传负数内部通过事务和行锁保证并发安全。function changeStock(PDO $db, int $warehouseId, string $skuId, int $delta, string $bizType, string $bizNo): bool { $db-beginTransaction(); try { $stmt $db-prepare( SELECT qty FROM inventory WHERE warehouse_id :wid AND sku_id :sid FOR UPDATE ); $stmt-execute([:wid $warehouseId, :sid $skuId]); $row $stmt-fetch(PDO::FETCH_ASSOC); $stockBefore $row ? (int)$row[qty] : 0; if (!$row) { $db-prepare( INSERT INTO inventory (warehouse_id, sku_id, qty) VALUES (:wid, :sid, 0) )-execute([:wid $warehouseId, :sid $skuId]); } $stockAfter $stockBefore $delta; if ($stockAfter 0) { throw new RuntimeException(库存不足禁止扣减到负数); } $db-prepare( UPDATE inventory SET qty :qty WHERE warehouse_id :wid AND sku_id :sid )-execute([:qty $stockAfter, :wid $warehouseId, :sid $skuId]); $db-prepare( INSERT INTO inventory_log (warehouse_id, sku_id, change_qty, before_qty, after_qty, biz_type, biz_no) VALUES (:wid, :sid, :delta, :beforeqty, :afterqty, :bizType, :bizNo) )-execute([ :wid $warehouseId, :sid $skuId, :delta $delta, :beforeqty $stockBefore, :afterqty $stockAfter, :bizType $bizType, :bizNo $bizNo, ]); $db-commit(); return true; } catch (Throwable $e) { $db-rollBack(); throw $e; } }这段代码里SELECT ... FOR UPDATE会锁住某个仓库下某个 SKU 的库存行另一个请求同时改同一个 SKU 时只能等前一个事务提交。$delta的正负决定了入库还是出库$bizType和$bizNo负责在流水里留下业务来源。注意的是$stockAfter 0的检查只是第一道防线线上真正防超卖还要靠UPDATE inventory SET qty qty - :num WHERE warehouse_id :wid AND sku_id :sid AND qty :num这种带条件更新返回受影响行数为 1 才代表扣减成功。4.2 调拨单源仓库和目标仓库必须同事务调拨是最容易出问题的场景。常见误用是先扣源仓库再单独加目标仓库两步之间如果报错或断电货就“丢”在中间了。正确做法是让源仓库扣减和目标仓库增加在同一个事务里完成function transferStock(PDO $db, int $fromWh, int $toWh, string $skuId, int $qty, string $bizNo): void { $db-beginTransaction(); try { // 扣源仓库流水类型 transfer_out changeStock($db, $fromWh, $skuId, -$qty, transfer_out, $bizNo); // 加目标仓库流水类型 transfer_in changeStock($db, $toWh, $skuId, $qty, transfer_in, $bizNo); $db-commit(); } catch (Throwable $e) { $db-rollBack(); throw $e; } }严格说这段代码有一个事务嵌套问题changeStock内部自己beginTransaction在 PHP 的原生 PDO 里嵌套事务不会真的开一个新事务内层commit会把外层也提交掉。所以放到真实项目里我一般会把changeStock内部的事务控制抽出来只保留 SQL 逻辑由外层来决定什么时候提交。上面这段代码的价值在于把“调拨必须同一个事务”这个顺序表达清楚源仓库扣减失败目标仓库增加就不会执行。4.3 盘点与期初库存也走流水很多系统在“期初建账”和“盘点调整”这两个地方偷懒直接UPDATE inventory SET qty 某个数。这样库存表是对的但流水账对不上后面 ERP 成本数据没跑通十有八九就是这里断了。提示期初库存也要生成一条biz_type opening的流水盘点差异则可以统一记成check方便月末对账。建议的业务类型和库存变化方向如下表biz_type业务场景change_qtyorder_in采购入库正数order_out销售出库负数transfer_out调拨出库源仓库负数transfer_in调拨入库目标仓库正数check盘点盈亏调整正数或负数opening期初建账正数这样整个系统就形成一条铁律项目代码里不应该有任何一段逻辑可以直接改inventory.qty所有变更必须经changeStock写流水。开发时可以定期查一下代码里有没有绕过它的UPDATE inventory语句发现一次就当场改掉。5. 部署配置与性能优化Nginx、PHP-FPM、MySQL 的参数怎么调5.1 Nginx PHP-FPM 站点配置与目录权限PHP 网页版 ERP 最常见的部署形态是 Nginx PHP-FPM MySQL源码包放服务器后重点注意入口目录和可写目录的权限。一个最小站点配置如下server { listen 80; server_name erp.example.com; root /var/www/erp/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }如果源码包是根目录直接放index.php就把root指到源码根目录同时把public之外的目录用location屏蔽掉。fastcgi_pass可以填127.0.0.1:9000也可以填unix:/run/php/php-fpm.sock后者在本地高并发下略好一点。部署后如果页面出现 502先看 PHP-FPM 有没有起来再看SCRIPT_FILENAME拼出来的路径对不对。5.2 PHP 与 MySQL 的关键参数进销存系统页面通常不大但报表、导出和月末结账这些操作容易超时。常见的调整如下; php.ini / php-fpm 池配置 memory_limit 512M max_execution_time 120 post_max_size 64M upload_max_filesize 64M; my.cnf 的 [mysqld] 段 innodb_buffer_pool_size 1G innodb_flush_log_at_trx_commit 2 max_connections 300 slow_query_log ON long_query_time 2memory_limit给大一点是为了导出 Excel、批量调整价格这类脚本post_max_size和upload_max_filesize要配套调否则导入商品图片时传大文件会被静默截断。innodb_buffer_pool_size一般是物理内存的 60% 左右1G 只是内网小系统的起步值。innodb_flush_log_at_trx_commit 2在断电时可能丢最后一秒事务追求强一致就设回 1内网系统通常 2 更平衡。配置项影响建议值memory_limit导出、批量脚本能否撑住512Mupload_max_filesize商品图片和附件上传64Minnodb_buffer_pool_sizeInnoDB 整体读写性能物理内存 60%long_query_time慢查询阈值2 秒开启慢查询后可以用这条命令定期检查grep -E Query_time|Rows_examined /var/log/mysql/mysql-slow.log | head -100凡是在 2 秒以上的查询直接看这几张表有没有建立前面提到的复合索引尤其是inventory_log(warehouse_id, sku_id, created_at)和business_order(status, order_type)。实践里 90% 的慢查询都能靠加索引解决不需要换机器。5.3 什么时候引入队列和缓存如果 ERP 要对接电商或门店端库存变更请求会突然变多这时候 PHP-FPM 的同步处理就不够看了。常见做法是引入 Redis 队列把出库请求先写进队列再由 PHP 的常驻进程框架的 queue worker 或 cron 轮询批量调用changeStock。这样库存入口仍然是唯一的只是调用方从 HTTP 同步变成了队列消费。inventory表的库存数不要直接丢 Redis 当“正式数据”Redis 只能做读取缓存的加速比如商品列表页显示库存时先读缓存回源时再查 MySQL。写操作永远走数据库事务否则缓存过期或没写进去的瞬间前台就会出现“能下单但仓库没货”的事故。参数调优到这里差不多了最后说一个经常被忽略的仓库布局不要把大文件存在代码目录里上传目录单独建一个/data/erp_uploads用软链接指过去备份时只备数据库和这个目录能省掉大量没必要的磁盘空间。6. 核对库存账与流水账用一条 SQL 找出仓库差异有些 ERP 项目用着用着成本数据不对最后追下来都是库存先乱。我验证系统是否正确的最小手段不是看报表而是把inventory账面数和inventory_log流水推算数做一次全量比较。前提是期初库存也按opening流水录进去了然后用下面的 SQLSELECT l.warehouse_id, l.sku_id, SUM(l.change_qty) AS calc_qty, i.qty AS real_qty, i.qty - SUM(l.change_qty) AS diff FROM inventory_log l LEFT JOIN inventory i ON i.warehouse_id l.warehouse_id AND i.sku_id l.sku_id GROUP BY l.warehouse_id, l.sku_id, i.qty HAVING diff 0 LIMIT 100;这条 SQL 的逻辑是把流水表按“仓库 SKU”汇总出应该有的库存再和库存表当前值相减差多少一眼就能看到。跑出来有差异的行就是账实不一致的位置。拿到差异行后通常按这个顺序排查先按biz_no找到对应的业务单据看单据状态是不是“已审核但库存未变动”再查有没有手工刷数据库的记录最后看盘点单是不是直接改的inventory表没有走check流水。确定差异出现在哪一单之后顺着inventory_log.created_at升序慢慢找第一条对不上的流水往往就是成本数据跑不通的源头。排查完毕修正数据时不建议直接改inventory.qty而是补一张check类型的调整单把差异冲平同时让流水账保持连续。这样下个月对账时还能看到这个仓库在什么时间点做过人工调整。整个过程不需要停机只需要在业务低峰跑一次这个 SQL或者把它挂在定时任务里每天凌晨跑一遍把diff 0的结果推给管理员就好。仓库超过 5 个之后把这个 SQL 挂到每日定时任务里能省掉大量手工对账的时间。本文还有配套的精品资源点击获取
返回列表