PHP云进销存源码深度拆解:部署、库存联动与权限设计
2026/9/16 16:05:03 网站建设 项目流程

简介:一套以PHP+MYSQL构建的云进销存ERP系统源码,面向中小型企业的销售、库存与员工管理场景,适合有PHP开发基础的技术人员直接部署或二次开发。系统采用B/S架构,覆盖购货、销货、仓库、商品、供应商、职员等模块,支持出入库、多仓库调拨、库存盘点、收款付款单据、采购/仓库/资金报表以及权限设置;2022独家版本还加入合同报价单打印,并修复了子账号无法查看新加客户的BUG。压缩包共1229个文件,其中501个PHP文件承载核心业务逻辑,191个JS脚本和49个HTML、42个CSS组成前端界面,另有图片、SQL数据库脚本及说明文档等,压缩后约24.39MB,目录结构清晰,便于按模块快速定位。已有92人学习,资源内包含可运行的完整源码、数据库脚本、功能清单与更新说明,可帮助读者快速搭建一套进销存系统,或作为ERP相关开发学习的参考案例。

1. 这套2022版PHP云进销存,为什么还值得从源码层面拆一遍

很多公司的进销存还停留在Excel加微信汇报的阶段,库存账、应收账、员工提成三套数对不上。这套PHP云进销存本质上是一个B/S结构的ERP,用PHP加MySQL跑起来,浏览器打开就能用,天然适合云部署。和网上流传的老版本相比,2022版多了合同报价单打印,修掉了子账号看不到新增客户的Bug,也补了一些报表维度,适合LNMP或宝塔环境下快速支起一套内部系统,也适合有PHP基础的工程师拿源码改成自己的进销存底座。我拆这套源码时最深的印象是:单表设计并不复杂,但库存联动和权限边界是决定系统能不能长期跑下去的关键。下面按环境部署、数据库、权限、排错四条线展开。

2. 环境搭建与源码部署:PHP版本兼容、目录权限和伪静态

2.1 部署环境选型:宝塔面板还是手工LNMP

这套源码的常见运行环境是Nginx + PHP + MySQL,很多生产环境跑在宝塔面板里。宝塔的好处是PHP扩展和站点配置都能在可视化界面里改,遇到PHP版本切换、伪静态配置、目录权限问题比命令行快很多。安装源码前,先把PHP扩展补齐:pdo_mysql、mysqli、gd、mbstring、curl、fileinfo。GD扩展缺失最典型的表现是登录页验证码不显示,这跟很多宝塔PHP验证码报错是同一个原因,在PHP设置里找到扩展,点开安装然后重启PHP-FPM即可。

location / { if (!-e $request_filename){ rewrite ^(.*)$ /index.php?s=$1 last; } }

上面是Nginx的伪静态配置,作用是把不存在的文件或目录请求全部Rewrite到index.php入口,由PHP框架解析路由参数s。源码如果基于ThinkPHP之类的框架,这条规则是标配;如果换成Apache,则要写.htaccess文件。配置完伪静态后一定要重启Nginx,否则页面会一直404。不要漏掉location /这段的作用域,站点配置里可能还有其他location规则,顺序靠前的规则会先匹配。

2.2 导入数据库与修改数据库连接配置

源码包里一般会带.sql文件或在安装向导里自动导入。用phpMyAdmin导入时先确认数据库字符集是utf8mb4,避免中文乱码。导入之后,找到配置文件,常见路径是application/database.php,也可能是config/db.php。数据库连接配置就像这样:

return [ 'hostname' => '127.0.0.1', 'database' => 'erp_cloud', 'username' => 'erp_user', 'password' => '你的密码', 'hostport' => '3306', 'charset' => 'utf8mb4', 'prefix' => 'erp_', ];

配置项里最容易被忽略的是prefix。安装时如果改过表前缀,这里必须同步,否则系统会提示数据表不存在。部署中常见的问题是数据库密码里带了特殊字符,比如#、@、$,在配置文件里需要根据语法规则处理。数组配置用单引号包裹就没有字符串转义问题,如果是PHP变量拼接方式,特殊字符很容易导致连接失败。配置保存后,用命令行验证能更清楚定位错误:

php -r "new PDO('mysql:host=127.0.0.1;dbname=erp_cloud', 'erp_user', '你的密码'); echo 'ok';"

执行后输出ok说明数据库连接成功。如果抛PDOException,先检查账号权限是不是只允许了localhost访问,远程连接请把MySQL绑定地址设为0.0.0.0并调整账号host。

2.3 初始化数据与管理员账号

数据库导入完成后,访问站点根目录,通常会出现安装引导页面,按步骤填写管理员账号和公司信息。如果源码直接给定了初始账号,那就登录后台做一次基础数据初始化。建议先停掉线上流量,把基础设置里的默认仓库、默认供应商、默认结算方式、税率都填好。这些基础数据决定了后续单据的带出值,现在不配置,后面每次录单都要手选,效率非常低。

2.4 部署验证与日志定位

部署成功后用curl验证首页是否正常:

curl -I http://你的域名或IP/erp

返回200说明Nginx和PHP链路通了,返回301/302则要核对站点根目录和伪静态。返回500时去PHP错误日志查看,宝塔面板里能直接看到错误日志路径,常见问题是php.ini里display_errors被关闭导致白屏,但运行环境里更建议关闭display_errors,把错误写入日志文件,方便按时间点定位。日志级别建议调到E_ALL,然后看PHP版本是否提示某个函数被废弃。

3. 核心模块与数据库设计:库存流水、多仓库调拨与盘点

3.1 关键数据表结构:商品、仓库、库存如何联动

进销存系统的表数量不会少,但核心关系其实很收敛:商品表管理产品主数据,仓库表管理物理或逻辑库位,库存表记录每个商品在每个仓库的数量。库存表的设计最值得注意,建议用唯一键保证同一商品在同一仓库只能存在一条记录。

CREATE TABLE `erp_stock` ( `id` INT NOT NULL AUTO_INCREMENT, `warehouse_id` INT NOT NULL COMMENT '仓库ID', `product_id` INT NOT NULL COMMENT '商品ID', `stock_qty` DECIMAL(15,3) NOT NULL DEFAULT 0 COMMENT '当前库存', `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_wh_prod` (`warehouse_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stock_qty用DECIMAL(15,3)而不用FLOAT,是因为金额和数量的累加过程如果走浮点,很可能会出现0.1+0.2不等于0.3的误差。库存字段宁可多保留三位小数,也不要为了省空间用整型。唯一键uk_wh_prod既避免了重复数据,也让后面的INSERT...ON DUPLICATE KEY UPDATE可以直接依赖它来做原子更新。

3.2 入库出库的库存更新:事务和原子操作缺一不可

采购入库的业务流程是:先写单据主表,再写单据明细,最后更新库存。如果只更新库存不写单据,报表里查不到入库来源;如果先写单据再更新库存时失败,库存账就和单据账不一致。所以整个流程必须放在一个事务里。常见做法是把库存操作封装成一个PHP类,类里的入库方法执行下面的SQL:

BEGIN; INSERT INTO erp_purchase_head (order_no, supplier_id, warehouse_id, biz_date, status) VALUES ('RK20241009-001', 2, 1, '2024-10-09', 1); SET @head_id = LAST_INSERT_ID(); INSERT INTO erp_purchase_item (head_id, product_id, qty, price) VALUES (@head_id, 101, 10, 12.50); INSERT INTO erp_stock (warehouse_id, product_id, stock_qty) VALUES (1, 101, 10) ON DUPLICATE KEY UPDATE stock_qty = stock_qty + 10; COMMIT;

注意这三条SQL在同一个事务里。先用LAST_INSERT_ID()拿到刚写入的主表ID,再写明细,最后用INSERT...ON DUPLICATE KEY UPDATE处理库存。这个写法的妙处在于:如果库存表里没有该商品对应仓库的记录,就直接插入;如果已有记录,会执行UPDATE把库存加10。相比SELECT后判断再INSERT,不仅少一次查询,还避免了并发下两个请求同时SELECT到不存在记录然后同时插入导致唯一键冲突的问题。

3.3 多仓库调拨:一减一加不能分开执行

调拨单本质上是把库存从调出仓转移到调入仓。如果拆成两条独立SQL,调出扣减成功、调入新增失败,总库存就平白少了一截。所以调拨必须在一个事务里做双向更新,推荐把扣减条件带上库存下限约束。

/** * 执行调拨 * @param int $fromWarehouseId 调出仓库 * @param int $toWarehouseId 调入仓库 * @param array $items 明细列表 */ function doTransfer($fromWarehouseId, $toWarehouseId, $items) { try { pdo()->beginTransaction(); foreach ($items as $item) { // 调出仓库扣减库存,带上 stock_qty >= :qty 防止负库存 $sqlMinus = "UPDATE erp_stock SET stock_qty = stock_qty - :qty WHERE warehouse_id = :from_wh AND product_id = :pid AND stock_qty >= :qty"; $affected = pdo()->execute($sqlMinus, [ ':qty' => $item['qty'], ':from_wh' => $fromWarehouseId, ':pid' => $item['product_id'], ]); if ($affected == 0) { throw new \RuntimeException('商品ID ' . $item['product_id'] . ' 库存不足'); } // 调入仓库库存增加 $sqlPlus = "INSERT INTO erp_stock (warehouse_id, product_id, stock_qty) VALUES (:to_wh, :pid, :qty) ON DUPLICATE KEY UPDATE stock_qty = stock_qty + :qty"; pdo()->execute($sqlPlus, [ ':to_wh' => $toWarehouseId, ':pid' => $item['product_id'], ':qty' => $item['qty'], ]); } // 写调拨单主表和明细表,这里省略 pdo()->commit(); } catch (\Throwable $e) { pdo()->rollBack(); throw $e; } }

这个函数里的UPDATE返回影响行数是关键。如果库存只有5件,但调拨单要求10件,WHERE条件中的stock_qty >= :qty不成立,影响行数为0,函数直接抛异常,事务回滚,不会出现负库存。调用方在编辑器或API层捕获RuntimeException后,把“库存不足”作为业务提示返回给用户。

3.4 库存盘点和差异调整:不要直接修改库存表

盘点功能通常先按仓库和商品导出账面库存,再录入实际盘点的数量。系统根据“实盘数量 - 账面数量”计算差异,差异数量不为0时生成一张盘点差异单。正向差异做入库调整,负向差异做出库调整。差异单的本质是一张普通出入库单,唯一的区别是关联了盘点单号,这样报表里能追溯差异来源。

下面这张表是盘点差异记录常用的字段说明,源码里字段名可能有差异,但含义基本一致。

字段名含义说明
stocktake_id盘点单号关联盘点主表
warehouse_id仓库ID盘点发生的仓库
product_id商品ID被盘点的商品
book_qty账面数量盘点时系统里的库存量
real_qty实盘数量人工清点的数量
diff_qty差异数量实盘-账面,正数盈,负数亏
status单据状态0草稿,1已审核,2已冲销
reviewed_by审核人记录是谁审核的差异单

实现时,盘点单审核后调用一次库存调整方法,不要直接UPDATE库存表。直接改库存表会留下口径黑洞,资金报表和采购报表会差出莫名其妙的数字。审核动作和库存调整也要放在一个事务里,避免审核成功了但库存没调,或者库存调了但盘点单还在草稿状态。

4. 权限控制、子账号客户数据隔离与打印模板改造

4.1 权限模型:菜单权限和数据权限分开看

很多进销存系统的权限管理只做了菜单权限,也就是先给角色勾选能访问哪些菜单,再把角色绑定到账号上。但菜单权限控制不了“某一个子账号能看到哪些客户”这类数据级问题。真正要跑通业务流程,数据权限必须单独考虑。比如销售员角色能看客户管理菜单,但只能看到自己名下的客户;仓库文员能看到所有库存记录,但不能看成本价和毛利。

这套源码里如果沿用网上老版本,数据权限通常是按字段过滤实现的,比如客户表里保存owner_id。权限逻辑一般写在控制器的查询方法里,判断当前登录用户的角色后追加条件。遇到性能问题,不要用PHP循环去过滤结果集,而是在SQL的WHERE里直接用IN子查询或用JOIN关联业务员客户关系表。

4.2 子账号看不到新加客户:BUG根因和修复方法

网上流传的老版常见问题,是子账号登录后老客户显示正常,新建的客户却看不到。问题出在新增客户时没有把当前子账号ID写入客户所属人字段。列表查询按所属人过滤,新客户的所属人为空,子账号自然查不到。修复方式是在新增客户的控制器里,明确写入当前登录用户ID:

$data['customer_owner'] = session('user_id'); $data['created_by'] = session('user_id');

这里的session('user_id')是当前登录账号的ID,具体函数名以源码为准,可能是$_SESSION['admin_id']或helper函数user_id()。写入created_by是为了保留创建人,方便后续追溯。

如果系统已经运行一段时间,库里堆积了owner为0的客户,需要回填。回填前先确认这些客户是由谁创建:

SELECT created_by, COUNT(*) AS cnt FROM erp_customer WHERE customer_owner = 0 OR customer_owner IS NULL GROUP BY created_by;

如果created_by也没有值,那只能根据操作日志或人工判断归属了。确认规则后执行回填:

UPDATE erp_customer SET customer_owner = created_by WHERE customer_owner = 0 OR customer_owner IS NULL;

回填后的下一步是修改列表查询过滤逻辑,建议用双条件:子账号只查owner等于自己的客户,管理员不加该条件。不要直接删掉过滤条件,否则子账号的客户数据会互相串,比看不到更麻烦。

4.3 子账号和角色授权的最佳实践

创建子账号时,建议按岗位角色分配,而不是一个账号一个权限。可以先用系统初始化的管理员账号登录,在系统设置-账户管理里创建“销售员”“仓库文员”“财务”三个角色,角色绑菜单和操作权限,再在员工管理里新增子账号并绑定角色。销售员角色的数据权限设为“仅本人客户”,仓库文员角色则不需要看金额字段,财务角色不保留单据审核权限,避免采购员既录单又审核的情况。

下面是常见的角色权限参考,你可以按公司实际流程调整:

角色客户管理采购管理销货管理仓库盘点收款付款报表查看
销售员仅本人可查看新增+编辑不可不可可看毛利
仓库文员不可不可出库确认可操作不可可看库存
财务只读只读只读不可审核全部

4.4 合同报价单打印:模板标签和美工样式踩坑点

新版源码强调合同报价单打印,这个功能通常在单据详情页点“打印”按钮,调用浏览器的打印功能。打印模板里是服务端渲染的HTML,常见的标签替换类似于:

<table class="print-table"> <tr><th>序号</th><th>商品编码</th><th>商品名称</th><th>数量</th><th>单价</th><th>金额</th></tr> {loop $items $key $item} <tr> <td>{$key+1}</td> <td>{$item.product_code}</td> <td>{$item.product_name}</td> <td>{$item.qty}</td> <td>{$item.price}</td> <td>{$item.amount}</td> </tr> {/loop} </table>

{$item.product_code}是模板引擎的变量占位符,循环标签{loop $items $key $item}会遍历商品明细数组。打印时最怕把整个HTML页面里导航栏都打印出来,解决方法是单独给打印区域加media print样式,常见的写法是:

@media print { body * { visibility: hidden; } #printArea, #printArea * { visibility: visible; } #printArea { position: absolute; left: 0; top: 0; width: 100%; } }

这段样式先把body下所有元素隐藏,再单独显示#printArea及其子元素。注意这里不能用display:none来隐藏其他元素,因为display:none会被继承影响打印区域。打印样式加上@page指令可以控制纸张大小和页边距,报价单一般用A4纸,需要在一页里放下的表细节数有限,明细超过20行时建议在模板里控制字号。

4.5 报表统计:成本ERP数据跑不通的常见排查

采购报表和资金报表对不上,通常是过滤条件不一致造成的。采购报表里用LEFT JOIN保留无供应商数据,如果汇总出来的total_amount为NULL,多半是明细表和主表关联字段写错,或单据状态没有设置成已审核。下面是一条典型的按月汇总采购金额的SQL:

SELECT s.supplier_name, DATE_FORMAT(h.biz_date, '%Y-%m') AS month, COALESCE(SUM(i.qty * i.price), 0) AS total_amount FROM erp_purchase_head h LEFT JOIN erp_purchase_item i ON h.id = i.head_id LEFT JOIN erp_supplier s ON h.supplier_id = s.id WHERE h.status = 1 GROUP BY s.supplier_name, DATE_FORMAT(h.biz_date, '%Y-%m');

COALESCE函数把NULL转为0,便于前端展示,避免出现没有采购金额的供应商记录。汇总结果如果和业务对不上,第一件事检查erp_purchase_head里status字段是否把草稿单也置为1,第二件事看有没有直接改过erp_stock表但没有生成对应单据。这个排查思路也适用于成本ERP数据跑不通的情况,先在源单和流水之间找断点,而不是去改报表SQL。

5. 进阶排错:库存负数、PHP兼容性修复与定时备份

5.1 库存出现负数怎么快速定位

库存出现负数大多数不是系统算错了,而是历史数据本身存在不平记录,或某个调拨单在旧逻辑下绕过了库存拦截。排查时先找出该商品最近每一笔库存变化流水,按时间顺序检查是什么单据导致库存穿过零点:

SELECT * FROM erp_stock_log WHERE product_id = 101 ORDER BY log_id ASC;

erp_stock_log表如果源码没有,就去查看单据明细表里的出入库记录,重点看审核时间、仓库ID和单据类型。一般逻辑是找到负数发生前后两笔流水,如果前一条流水是手动UPDATE造成的,说明有人直接改了库存表;如果前一条是调拨单,就检查调拨单有没有完整走事务。应急时可以用一条盘点差异单把负数调整回零,不要直接UPDATE库存字段。

5.2 PHP 7.4兼容性修复:mysql_*函数迁移到PDO

老版本源码常出现Fatal error: Uncaught Error: Call to undefined function mysql_connect(),这是PHP 7.4移除mysql_*扩展导致的。正确做法是把数据库操作全部迁移到PDO或mysqli,不用mysql_query。如果源码里数据库封装类很集中,替换成本低很多。下面是一个最小化的PDO查询封装:

function db_query($sql, $params = []) { static $pdo = null; if ($pdo === null) { $pdo = new PDO( 'mysql:host=127.0.0.1;dbname=erp_cloud;charset=utf8mb4', 'erp_user', '你的密码', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } $stmt = $pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); }

这里的PDO::ERRMODE_EXCEPTION会让SQL报错直接抛异常,方便定位问题。参数绑定用$params数组,避免直接拼SQL导致注入。替换完mysql_query后,还要把原来的$rs = mysql_fetch_array($result)改成foreach (db_query(...) as $row)的写法,很多老代码的循环体要跟着改。PHP错误处理上,建议在入口文件里加上set_exception_handler,把未捕获异常写入运行时日志,这样上线后遇到白屏不用再翻Nginx错误日志。

5.3 定时备份:mysqldump加--single-transaction

进销存数据直接关系到钱,必须做定时备份。备份命令要加--single-transaction,这个参数在InnoDB下启用一致性快照,备份过程中不会锁表,业务继续写也不会导致备份失败。错误示例是用--lock-tables,在凌晨跑批时会把正在写库的表锁住,导致前台卡单。

mysqldump -uerp_user -p'密码' \ --default-character-set=utf8mb4 \ --single-transaction \ --quick \ erp_cloud > /backup/erp_$(date +%Y%m%d_%H%M).sql

命令里--quick让逐行输出而不是一次性载入内存,--default-character-set=utf8mb4确保备份文件的字符集和库一致,恢复时不容易乱码。把这段命令写进crontab,每天凌晨2点执行,再结合宝塔面板的数据库备份功能保留最近7天版本。恢复时先创建空库,再执行mysql -uerp_user -p'密码' erp_cloud < 备份文件.sql,恢复后记得进入系统检查库存总数和报表数据是否和备份前一致。

对于这套PHP云进销存源码,改完上面几个问题点后,再用管理员身份走一遍采购入库、销售出库、调拨、盘点四个核心流程,基本就能验证新旧版本的差异是否真正修到位了。

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

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

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

立即咨询