PHP进销存源码二次开发:库存模型、事务与部署避坑指南

PHP进销存源码二次开发:库存模型、事务与部署避坑指南 简介这是一份PHP进销存管理系统的完整源码包面向需要开发或二次开发中小型进销存项目的PHP开发者重点解决商品入库、销售、库存统计等业务场景的快速搭建问题。资源共2000个文件包含2330个php程序文件、468个html页面、350个js脚本、119个css样式表以及png/jpg/gif等图片素材同时附带sql数据库脚本、txt说明文档和readme压缩包整体约71.84MB。系统支持php5.6及以上版本推荐php7.1搭配mysql5.6以上环境并需开启rewrite模块。源码目录结构清晰可帮助学习者理解进销存模块划分、数据库设计以及前后端交互逻辑也能直接部署用于小型企业管理。目前已有1338人学习下载适合PHP初学者跟练也适合有经验的开发者在此基础上扩展功能。1. 为什么大多数PHP进销存系统源码改完数据库就要重写拿到一份PHP进销存管理系统源码.zip很多人第一反应是解压、传服务器、配个数据库然后就能在浏览器里看到登录页。实际做下来会发现这套源码的部署难点从来不在功能而在数据模型和业务闭环的耦合方式。商品、库存、采购、销售、报表这五块如果只是各自写CRUD那系统只能演示不能跑真实业务。真正的进销存系统核心是库存流水的记录、事务边界的划分以及单据状态之间的转换规则。这篇文章不打算逐行解读某个具体项目的代码而是把一套可迁移的方案讲清楚表结构怎么设计才能支持对账PHP侧怎么用事务和锁防超卖部署在不同PHP版本和Web服务器上时哪些配置必须改。适合刚接手这类源码、想把二次开发风险和上线事故率压下来的读者。代码可以直接套到自己的项目里参数和坑也都说明白。2. 进销存数据模型与PHP侧的表结构设计进销存系统的数据模型比普通后台管理系统多一个关键约束库存始终是状态而非事实。事实是每一笔入库单和出库单库存只是这些单据累加的结果。因此表结构设计必须从「记录流水」出发而不是从「维护一个库存字段」出发。这一章先讲清楚表拆分的原则再给出PHP侧可用的PDO封装和建表SQL示例。2.1 商品表、库存表为什么不能合成一张常见的新手设计是把商品信息和当前库存数量放在同一张表里比如products表里加一个stock_qty字段。这套结构在演示项目里没问题但一旦出现采购退货、销售退货、盘点差异、报损报溢stock_qty字段就会变成谁都能改的「全局变量」最终和真实库存对不上。正确的做法是把库存拆成三层商品主数据表只存商品编码、名称、规格、单位、状态以及最近一次采购价、零售价等业务属性不直接存库存数。库存余额表按商品ID 仓库ID唯一存储当前可用库存、冻结库存、总库存这个表只由库存操作事务更新。库存流水表每笔入库、出库、盘点、调拨都插入一条不可修改的流水记录变更前后的数量差。这个结构在PHP里对应的实体关系很清晰。商品表是主表库存余额表是冗余的快照流水表是审计证据。查询库存时优先读余额表对账时则通过流水表重新累加校验。三张表相互关联但不能简化为一张大宽表。下面是一组精简的建表SQL省略了公司租户ID等扩展字段保留核心约束CREATE TABLE product ( id bigint unsigned NOT NULL AUTO_INCREMENT, sku varchar(64) NOT NULL COMMENT 商品编码, name varchar(128) NOT NULL COMMENT 商品名称, spec varchar(64) DEFAULT COMMENT 规格, unit varchar(16) DEFAULT 件 COMMENT 单位, purchase_price decimal(12,2) NOT NULL DEFAULT 0.00, sale_price decimal(12,2) NOT NULL DEFAULT 0.00, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id), UNIQUE KEY uk_sku (sku) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE stock_balance ( id bigint unsigned NOT NULL AUTO_INCREMENT, product_id bigint unsigned NOT NULL, warehouse_id bigint unsigned NOT NULL, quantity int NOT NULL DEFAULT 0 COMMENT 可用库存, locked_quantity int NOT NULL DEFAULT 0 COMMENT 锁定的库存, PRIMARY KEY (id), UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE stock_flow ( id bigint unsigned NOT NULL AUTO_INCREMENT, product_id bigint unsigned NOT NULL, warehouse_id bigint unsigned NOT NULL, biz_type tinyint NOT NULL COMMENT 1采购入库 2销售出库 3盘点调整 4退货 5报损, change_qty int NOT NULL COMMENT 正数入库负数出库, before_qty int NOT NULL DEFAULT 0, after_qty int NOT NULL DEFAULT 0, biz_no varchar(32) NOT NULL COMMENT 关联单号, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_product (product_id), KEY idx_biz_no (biz_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表逻辑有几个必须说明的参数quantity用int避免小数精度问题purchase_price和sale_price用decimal(12,2)PHP侧对应字符串处理不要用float否则金额会累积误差。库存字段不加unsigned约束因为库存操作中可能出现冻结、预占等临时负数状态加unsigned会导致事务回滚时报错。流水表不更新不删除这是审计的基础。2.2 用PDO预处理构建通用查询层PHP连接MySQL的写法在老源码里五花八门从mysql_*函数到mysqli再到PDO都有。现在接手一套源码首要任务是把所有数据库操作统一到 PDO 上。PDO 并不是一个魔鬼公积金而是一个一致性的调用接口它支持预处理语句可以有效防止 SQL 注入也方便切换数据库驱动。一个最小的 PDO 连接配置可以这样写?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::ATTR_PERSISTENT false, ]; try { $pdo new PDO($dsn, root, password, $options); } catch (PDOException $e) { // 记日志不要给用户看原始异常 error_log($e-getMessage()); exit(数据库连接失败); }参数说明ATTR_EMULATE_PREPARES设置为false时PDO 会使用 MySQL 原生预处理整数和字符串参数类型不会因为引号问题导致索引失效。ATTR_PERSISTENT在进销存管理系统里建议关闭因为长时间运行的 PHP-FPM 进程复用连接后事务隔离级别可能残留上一个请求的设置。连接字符集直接放在 DSN 的charsetutf8mb4里不要在连接后再执行SET NAMES。有了这个 PDO 实例下一步是封装一个通用的查询函数。不需要引入完整的 ORM进销存逻辑最怕的是把业务规则散落在模型里用简单的query/execute反而更容易维护。下面是一个基础封装?php class Db { private static PDO $pdo; public static function init(PDO $pdo): void { self::$pdo $pdo; } public static function fetchAll(string $sql, array $params []): array { $stmt self::$pdo-prepare($sql); $stmt-execute($params); return $stmt-fetchAll(); } public static function fetchOne(string $sql, array $params []): ?array { $stmt self::$pdo-prepare($sql); $stmt-execute($params); $row $stmt-fetch(); return $row false ? null : $row; } public static function execute(string $sql, array $params []): int { $stmt self::$pdo-prepare($sql); $stmt-execute($params); return $stmt-rowCount(); } }这段代码的关键点在于fetchOne返回?array而不是固定数组这样在判断「商品是否存在」时可以明确区分false和空数组。execute返回rowCount()但要注意 MySQL 的rowCount()在 UPDATE 语句没有改变任何行时返回 0不要用它判断「更新失败」而应该依赖受影响行数是否为 1。这套封装虽然简单但对进销存系统来说已经够用后续写库存事务时可以直接复用。2.3 字段类型、索引和字符集的三个决策点进销存系统的表一旦上线数据量不会特别大几百万条流水已经很极端但索引设计仍然要提前规划。第一个决策点是所有表都用utf8mb4而不是utf8因为商品名称里可能出现 emoji 字符或者生僻字utf8mb4是 MySQL 8.0 的默认字符集兼容性最好。第二个决策点是金额字段全部使用decimal长度统一decimal(12,2)如果系统涉及多币种可以加currency字段但不要用浮点。第三个决策点是在流水表上建立联合索引比如(product_id, created_at)而不是单独给product_id建索引这样查询某个商品的近期流水时只需走一个索引。一个实际遇到过的坑是老源码在商品表sku字段上使用VARCHAR(255)并建立普通索引导致连表查询时排序开销很大。正确做法是VARCHAR(64)并建立唯一索引因为 SKU 编码通常有规范的长度。索引不是越多越好进销存系统的核心查询路径集中在「单据表按单号查」「库存表按商品仓库查」「流水表按商品和时间段查」围绕这三条路径建索引就够了。3. 核心业务闭环采购入库、销售出库与库存事务有了数据表接下来就要处理业务逻辑。进销存系统最重要的不是界面多漂亮而是每一步操作产生的数据能否形成闭环。采购入库增加库存销售出库减少库存这两个动作必须和单据状态严格绑定否则会出现「订单已经取消但库存被扣了」的情况。3.1 库存流水表让对账不再靠数订单实体表的操作流程可以用一句话概括任何库存变动先有单据再有流水最后更新余额表。如果这个顺序被反过来就会出现数据不一致。很多源码的问题在于销售出库时直接执行UPDATE stock_balance SET quantity quantity - 1然后才插入销售单和流水。一旦更新库存成功、插入流水失败整个数据就乱了。正确的实现方式是把这三个动作放在同一个事务里。PHP 代码可以这样组织?php $pdo-beginTransaction(); try { // 1. 检查商品是否存在且上架 $product Db::fetchOne( SELECT id, status FROM product WHERE id ? FOR UPDATE, [$productId] ); if (!$product || $product[status] ! 1) { throw new RuntimeException(商品不可销售); } // 2. 锁定库存行 $balance Db::fetchOne( SELECT quantity FROM stock_balance WHERE product_id ? AND warehouse_id ? FOR UPDATE, [$productId, $warehouseId] ); if (!$balance || $balance[quantity] $qty) { throw new RuntimeException(库存不足); } // 3. 插入销售单状态为已出库 Db::execute( INSERT INTO sale_order (order_no, product_id, warehouse_id, quantity, status) VALUES (?, ?, ?, ?, 1), [$orderNo, $productId, $warehouseId, $qty] ); // 4. 更新库存余额 Db::execute( UPDATE stock_balance SET quantity quantity - ? WHERE product_id ? AND warehouse_id ? AND quantity ?, [$qty, $productId, $warehouseId, $qty] ); // 5. 插入库存流水 Db::execute( INSERT INTO stock_flow (product_id, warehouse_id, biz_type, change_qty, before_qty, after_qty, biz_no) VALUES (?, ?, 2, ?, ?, ?, ?), [ $productId, $warehouseId, -$qty, $balance[quantity], $balance[quantity] - $qty, $orderNo ] ); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; }这段代码的核心是FOR UPDATE锁库存行。注意第 2 步先查询并锁定stock_balance中对应的行第 3 步插入销售单第 4 步更新库存。为什么先锁行再更新因为如果不锁两个并发请求可能同时读到可用库存为 10然后各自扣 1最后余额变成 9实际卖了 2 件这就是超卖。加了FOR UPDATE后第二个请求必须等第一个事务提交后才能读到锁更新后的值。第 4 步的更新语句里加了一个WHERE quantity ?条件这层判断作为乐观兜底。即使前面锁行后库存没有变化这里也能防止因逻辑错误导致的负数库存。如果更新影响行数为 0说明库存已在其他事务中被修改此时事务回滚给用户返回「库存不足」。3.2 在PHP中实现原子扣减库存上一节展示的是「边查边改」的写法实际上还可以用一条 UPDATE 完成扣减然后用rowCount()判断是否成功。这种写法更短但无法获取扣减前的库存数来写流水。常见的折中方案是先查一次拿到before_qty再执行 UPDATE 扣减判断rowCount()是否为 1如果是则用内存中的before_qty计算after_qty插入流水。这套方案不需要FOR UPDATE因为 UPDATE 本身会加行锁但有一个前提查询和更新之间不能有太长的逻辑计算否则并发下第二个查询可能读到旧值。下面给出一个更简洁的原子扣减示例?php $affected Db::execute( UPDATE stock_balance SET quantity quantity - ? WHERE product_id ? AND warehouse_id ? AND quantity ?, [$qty, $productId, $warehouseId, $qty] ); if ($affected ! 1) { throw new RuntimeException(库存扣减失败请重试); } $before Db::fetchOne( SELECT quantity FROM stock_balance WHERE product_id ? AND warehouse_id ?, [$productId, $warehouseId] )[quantity]; Db::execute( INSERT INTO stock_flow (product_id, warehouse_id, biz_type, change_qty, before_qty, after_qty, biz_no) VALUES (?, ?, 2, ?, ?, ?, ?), [$productId, $warehouseId, -$qty, $before $qty, $before, $orderNo] );这段代码的问题在于$before是在 UPDATE 之后查到的值所以插入流水时before_qty需要使用$before $qty来还原扣减前的值否则流水就错了。写成$before $qty看起来有点绕但逻辑是正确的。这种方式的好处是不需要显式事务单条 UPDATE 本身就是原子操作。缺点是如果后续还要更新订单状态或关联表就必须手动开启事务否则中途失败会导致流水存在但订单缺失。因此我的建议是业务包含多个数据表变动时一律使用第一节的beginTransaction方案只有单表扣库存场景才用这种简化写法。3.3 采购和销售单的状态机设计进销存的单据不能只有「未完成/已完成」两个状态。采购单会经历草稿、待审核、部分入库、已入库、已关闭销售单会经历待支付、待发货、部分出库、已完成、已退货。如果只用两个状态业务一复杂就不得不加脏字段来标记。在PHP中实现状态机不需要引入第三方包用一个status字段加一个允许转换的映射表就够了。例如采购单的状态转换?php $allowedTransitions [ 0 [1, 2], // 草稿 - 待审核 / 作废 1 [2, 3], // 待审核 - 已审核 / 驳回 2 [3, 4, 5], // 已审核 - 部分入库 / 完成入库 / 强制关闭 3 [2, 5], // 部分入库 - 继续入库 / 关闭 4 [5], // 已入库 - 关闭如退货 5 [], // 已关闭终态 ];每次状态流转前先查allowedTransitions[旧状态]是否包含目标状态不包含直接抛异常。这个设计可以防止两种事故一是「已入库」的采购单还能被修改数量二是「已关闭」的订单被重新打开。在控制器中统一通过一个TransitionService来处理状态变化而不是在每一个 action 里写update status 2。这种集中管理的方式后续接审批流或者对接 ERP改动成本会小很多。状态机还有一个细节存在「部分入库」这类中间状态它必须依赖子表明细来计算。例如采购单有明细表purchase_order_item每次入库时累加received_qty当它等于order_qty时主单自动转为「已入库」。这个判断逻辑建议放在事务内通过查询明细表聚合值来更新主单状态而不是依赖前端传过来的布尔值。4. 部署PHP进销存系统时最容易踩的三个坑源码能在本机跑通不代表能在生产环境部署成功。进销存系统涉及文件上传、Excel导出、报表打印、定时任务这些功能在不同PHP版本和服务器环境下表现差异很大。这一章把三类高频部署问题拆开讲。4.1 PHP版本差异从5.6迁到8.x的兼容处理老一批进销存源码大量使用mysql_*系列函数、each()、list()嵌套这些在 PHP 7.0 之后就没了。手头这份源码如果还挂在 PHP 5.6 上迁移到 PHP 8.x 需要处理几个高频点第一把mysql_query一类函数改为 PDO 或 mysqli。第二each()函数已被移除用foreach替代。第三count()在 PHP 8.0 之后对非数组类型会抛 TypeError之前只给警告。第四字符串和数组的比较行为变更PHP 8.0 会报异常。一个比较隐蔽的坑是 PHP 8.0 中错误抑制符对某些致命错误不再生效导致以前被压下去的语法错误直接暴露。迁移时可以用php -l批量检查语法再使用 Rector 这类工具做自动升级但人工检查业务逻辑仍然必要。部署前一定要在php.ini里设置error_reporting E_ALL ~E_DEPRECATED防止过时的写法刷爆日志。下面列出兼容性检查的常用命令# 检查当前PHP版本 php -v # 递归检查所有PHP文件的语法错误 find . -name *.php -print0 | xargs -0 -n1 php -l # 搜索已废弃的函数调用 grep -rn mysql_connect\|each( --include*.php .php -l只能检查语法不能检查运行时行为。比如函数签名变了php -l不会报错要真正跑一次业务流程才能发现。因此我在迁移后会写一个冒烟脚本顺序执行商品新增、采购入库、销售出库、库存查询四个动作任何一个抛异常就能立刻定位。4.2 文件上传与目录权限进销存系统通常有商品图片上传、Excel 导入导出功能。上传功能最常出问题的是目录权限和 PHP 配置大小限制。解压源码后常见的uploads/目录会设置为 777 权限这在本地可跑生产环境却风险极高。正确的做法是把上传目录设置为750属主为运行 PHP 进程的用户如www-data或php-fpm用户目录内禁止执行 PHP 脚本。可以在上传目录下放一个.htaccess禁用脚本执行适用于 Apachephp_flag engine off RemoveHandler .php .phtml .php3如果是 Nginx则在location配置中指定不处理 PHPlocation ^~ /uploads/ { location ~ \.(php|php5|phtml)$ { deny all; } }PHP 侧的参数也需要调整。upload_max_filesize和post_max_size默认值都很小如果源码支持批量导入 Excel建议在php.ini或入口文件中设置upload_max_filesize 20M post_max_size 20M max_file_uploads 20注意post_max_size必须大于等于upload_max_filesize否则上传大文件时会报「服务器拒绝了上传」或「表单数据不完整」。修改后要执行php-fpm -t检查配置语法然后重启 PHP-FPM 进程。4.3 伪静态与URL重写规则进销存系统的后台地址通常有多个参数比如index.php?modstockactioninid3。如果源码自带路由那还需要配置伪静态才能去掉index.php。Apache 环境一般在根目录放.htaccess但很多新手把 CopyShare 的配置直接扔进 Nginx导致 404。Nginx 下的常见配置片段location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }if (!-e $request_filename)的意思是当请求的文件和目录都不存在时才将 URL 重写到入口文件。这个写法可以避免静态资源如图片、CSS 文件也被重写。另一种更稳妥的写法是不用if直接用 try_fileslocation / { try_files $uri $uri/ /index.php?$query_string; }try_files的语义是先尝试$uri再尝试$uri/目录都不存在则交给/index.php?$query_string处理。这个配置比if更简洁但要注意$query_string变量不要被覆盖。如果源码使用$_GET获取路由参数重写后参数会包含在$_SERVER[QUERY_STRING]中PHP 侧可以正常解析。伪静态还有一个容易忽略的坑是如果后端没有统一的入口只有某个目录下的脚本需要重写那么location /会干扰其他目录。此时应该把重写限制在源码的public或web目录下而不是整个站点。部署时先检查源码目录结构如果入口文件在public/下Nginx 的root应指向public/这样其他目录不会暴露在 URL 中。5. 用一次性脚本验证进销存逻辑避免上线后才炸最后一章分享一个实用技巧不用写测试框架只需要一个 PHP 命令行脚本就能验证库存事务和状态机是否正确。这个脚本适合在部署完成后执行把人工点击界面的操作变成可重复的校验过程。先准备一个测试表结构中包含商品和库存余额。脚本的执行流程是初始化一个商品库存为 100模拟 20 个并发请求同时抢购 10 件商品最终检查库存是否变成 0 或 90并且流水条数是否符合预期。实现时会用到 PHP 的 pcntl 扩展如果服务器没装可以用 MySQL 事务模拟串行请求但对并发场景的验证效果略差。?php // test_stock.php require __DIR__ . /bootstrap.php; $productId 1; $warehouseId 1; // 初始化库存 Db::execute( UPDATE stock_balance SET quantity 100 WHERE product_id ? AND warehouse_id ?, [$productId, $warehouseId] ); $pid pcntl_fork(); for ($i 0; $i 20; $i) { // 每个子进程尝试扣减 10 件商品 if ($pid 0) { try { $pdo getPdo(); $affected Db::execute( UPDATE stock_balance SET quantity quantity - 10 WHERE product_id ? AND warehouse_id ? AND quantity 10, [$productId, $warehouseId] ); exit($affected 1 ? 0 : 1); } catch (Throwable $e) { exit(1); } } } // 父进程等待所有子进程结束 $successCount 0; for ($i 0; $i 20; $i) { $status null; pcntl_waitpid($pid, $status); if (pcntl_wexitstatus($status) 0) { $successCount; } } $balance Db::fetchOne( SELECT quantity FROM stock_balance WHERE product_id ? AND warehouse_id ?, [$productId, $warehouseId] ); if ($successCount 10) { fwrite(STDERR, 错误成功次数 {$successCount}存在超卖\n); exit(1); } $expectQty 100 - $successCount * 10; if ((int)$balance[quantity] ! $expectQty) { fwrite(STDERR, 错误库存为 {$balance[quantity]}期望 {$expectQty}\n); exit(1); } echo 通过成功扣减 {$successCount} 次剩余库存 {$balance[quantity]}\n;这段脚本的核心判断是$successCount不能超过 10因为总库存 100每次扣 10最多只有 10 次成功。如果脚本输出「成功 11 次」说明库存扣减语句失去了原子性。常见原因是stock_balance.quantity字段加上了UNSIGNED约束导致负数被保护当 UPDATE 超过库存时报错回滚但事务没有正确使用数据库底层采用乐观锁而不是行锁。验证状态机的脚本更简单从草稿状态逐级推进检查非法转换是否抛出异常。可以把这套测试脚本挂在部署脚本的post-install阶段每次更新代码后自动运行。生产环境不建议直接跑并发脚本可以在测试库先执行一遍然后把相同 SQL 语句在预发布环境跑一次确保表和索引结构一致。进销存系统的核心价值在于数据准确性和可追溯性。写完库存事务、状态机再配上这个一次性验证脚本部署上线后的告警就会少很多。最后再强调一个细节所有库存流水表的created_at字段按DATETIME(3)或者DATETIME(6)保存因为一秒钟内可能有多笔同商品流水毫秒级精度才能保证排序和审计的正确性。本文还有配套的精品资源点击获取