
简介面向中小企业与PHP开发者的网页版进销存源码实现多仓库ERP管理覆盖商品、订单、客户及库存等常见业务模块适合作为进销存系统选型或二次开发基础。压缩包共1423个文件、约20.67MB以PHP逻辑代码为主辅以JS/CSS/HTML前端资源、PNG/GIF图片素材、SQL数据库脚本及配置文件目录划分清晰。源码附带完整数据库文件和后台管理入口默认管理员账号admin、密码admin888安装导入后即可使用同时支持多仓库数据记录便于企业按仓管理货品与单据。对于希望快速部署进销存系统或研究PHP业务系统架构的开发者是一套可运行、可参考的完整案例。目前已有216人学习下载兼具实用与学习价值。1. PHP网页版进销存多仓库才是ERP源码的核心难点把进销存做成PHP网页版是ERP源码里最常见、也最容易翻车的一类系统。业务上说起来只有三件事采购、销售、库存真正的难点不在页面CRUD而在“多仓库”这三个字——同一款商品在多个仓库里各有独立库存出库要指定发货仓调拨要双边同时记账盘点之后余额还要对得上每一笔流水。这类系统多数跑在ThinkPHP 3.2、CodeIgniter或者原生PHPSmarty的老代码上MySQL存数据浏览器直接访问就能操作一台服务器就能撑起一家中小企业的ERP系统业务流程。适合谁读给企业内部做管理系统的PHP工程师接手老进销存源码打算二次开发的以及想从零搭一套多仓库进销存验证自己设计能力的人。怎么看一套源码能不能直接用先别看页面先看两件事库存表里有没有warehouse_id流水表里有没有记录变动前和变动后的数量。这两列决定它是真多仓系统还是把多套库存硬塞进一张表里的半成品。2. 技术选型与整体架构PHP进销存源码怎么搭才省心进销存系统听起来大落到技术选型上其实没有太多可炫技的地方。稳定的PHP版本、一台MySQL、一台Redis就构成了绝大多数系统的全部运行环境。选型的原则不是追新而是“团队熟悉度够、部署成本低、数据不出错”。2.1 为什么PHP做ERP进销存到现在仍是主流中小企业上一套ERP商业套装软件动辄几十万授权费Java或.NET团队的人力成本也高。PHP源码的进销存系统部署便宜改起来直接改文件招人容易跑个五六年不换技术栈是常事。网页版本身就是B/S架构客户端免安装总部和分仓的同事打开浏览器就能录单这是这类系统能长期留在中小企业里的根本原因。另一个容易被忽略的因素是历史包袱。很多公司手里的进销存源码已经跑了好几年里面有大量业务规则、报表和权限逻辑重新用别的语言写一遍风险极高。所以讨论选型不只是讨论“新项目用什么”更多是在讨论“老源码怎么续命”。2.2 技术栈怎么选老源码与新写系统的差别新写和改造老源码选型逻辑完全不同。新写可以直接上PHP 8和现代框架老源码则要优先考虑兼容性不要一上来就重构。技术项新写系统常见选择老源码改造常见选择PHP版本PHP 8.1 / 8.2PHP 5.6 或 7.4视老代码兼容性而定数据库MySQL 8.0 InnoDBMySQL 5.7避免老代码SQL语法不兼容数据访问PDO 预处理保持原有 mysqli / PDO不强行换ORM框架Laravel / ThinkPHP 6保持原有框架只修不重写缓存Redis 6Redis用于单号和热点库存前端单页应用或服务端渲染jQuery时代老页面逐步改善老PHP源码最容易踩的坑是PHP版本迁移。PHP 7.0移除了mysql_*系列函数PHP 5.6 的代码直接放到 PHP 7.4 上会大面积报错老代码里常用的魔术引号、短标签、同函数名大小写混用也都依赖旧运行时的宽松行为。新写系统则直接用PDO预处理把SQL注入问题从根上解决同时保证所有查询走同一个事务连接。这里还要泼一盆冷水进销存是强事务、强一致性的业务不要为了追求架构时髦去做微服务。多个服务互相调用来完成一次出库扣减中间任何一环断了库存就悬在半空。常见做法是把采购、销售、库存、调拨放在一个PHP应用里所有库存变更都在同一个事务内完成单体应用反而是这类系统最可靠的形态。2.3 先判断一套PHP源码是不是真的多仓拿到一套现成的PHP进销存源码先执行一条SQL看看库存表的结构SHOW CREATE TABLE product_stock;重点看两处一是有没有warehouse_id字段二是这张表上有没有(warehouse_id, product_id)的联合唯一键。两个条件都满足才说明系统在设计上就支持多仓库。如果表里没有warehouse_id这套源码多半是“单仓版”硬改成多仓常见做法是给商品加个“仓库”字段或者把同一件商品在数据库里复制成多条记录。这种改法很快会在报表上露馅按商品汇总库存时数量翻倍调拨时找不到对应的库存行出库时不知道扣的是哪个仓。提示这招适合所有老源码接手场景。一个系统是不是真多仓五分钟就能检查完不用等上线后让业务部门来骂。真正的多仓库设计不是多套数据库也不是一套数据库里放多套库存表而是所有仓库的库存行共存于一张表用warehouse_id区分用唯一键约束“同一个仓库里同一个商品只能有一行”。后面所有进销存单据、流水、报表都围绕这个唯一键展开。3. 多仓库进销存数据模型核心表结构与索引设计数据模型是整套进销存的骨架。仓库表、商品表、库存表、流水表四张表就能撑起核心业务闭环。再复杂的ERP系统往深处拆也是这个底子。3.1 仓库、商品、库存三张基础表怎么建先看基础表DDL这三张表解决“有哪些仓、有哪些货、货在哪”的问题CREATE TABLE warehouse ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) NOT NULL COMMENT 仓库编码, name VARCHAR(64) NOT NULL COMMENT 仓库名称, is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, created_at DATETIME NOT NULL, UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT仓库表; CREATE TABLE product ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(64) NOT NULL COMMENT 商品编码, name VARCHAR(128) NOT NULL COMMENT 商品名称, unit VARCHAR(16) NOT NULL COMMENT 单位, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; CREATE TABLE product_stock ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, warehouse_id INT UNSIGNED NOT NULL COMMENT 仓库ID, product_id INT UNSIGNED NOT NULL COMMENT 商品ID, quantity INT NOT NULL DEFAULT 0 COMMENT 可用库存, locked_quantity INT NOT NULL DEFAULT 0 COMMENT 锁定库存, updated_at DATETIME NOT NULL, UNIQUE KEY uk_wh_prod (warehouse_id, product_id), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;product_stock上的唯一键是整个设计的关键。它保证同一商品在同一仓库只有一行数据库存的加减都作用在这一行上天然防止“同一个SKU在一个仓里出现多行”的脏数据。available_quantity用quantity表示locked_quantity留给订单占用、出库冻结这类场景。为什么不把库存直接存在product表里因为product表里加一个quantity字段就只能表示总库存多仓库时要么拆商品编码要么维护多个字段完全没法支持按仓查询和调拨。正确的做法永远是“商品一张表、库存一张表”用warehouse_id product_id连接。3.2 库存流水表ERP数据跑不通的根源在前置和后置数量库存表是结果流水表是过程。很多进销存源码只维护库存表的当前值不做流水记录这样一旦某个单据被反审核、被修改或者程序里漏了更新库存对不上时完全无从查起。真正的ERP系统业务流程里流水表必须记录变动前和变动后的数量。CREATE TABLE stock_flow ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, flow_no VARCHAR(32) NOT NULL COMMENT 流水号, warehouse_id INT UNSIGNED NOT NULL COMMENT 仓库ID, product_id INT UNSIGNED NOT NULL COMMENT 商品ID, bill_type VARCHAR(32) NOT NULL COMMENT 单据类型PURCHASE_IN/SALE_OUT/TRANSFER/AUDIT, bill_no VARCHAR(32) NOT NULL COMMENT 关联单据号, change_type ENUM(IN,OUT) NOT NULL COMMENT 入库还是出库, change_qty INT NOT NULL COMMENT 变动数量, before_qty INT NOT NULL COMMENT 变动前库存, after_qty INT NOT NULL COMMENT 变动后库存, created_at DATETIME NOT NULL, KEY idx_flow_wh_prod (warehouse_id, product_id, created_at), KEY idx_flow_bill (bill_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;一旦流水表缺失后续做成本核算、进销存报表、审计追溯全都无从下手。所以任何一个新写或改造的库存模块我一般都会强制要求留下这张表并且每条流水都必须带before_qty和after_qty。有了这两个字段哪怕库存表被改坏了也能靠流水重放重建。3.3 单据主表和单号生成单据是业务的载体分主表和明细表。以采购入库单为例主表存单据号、供应商、仓库、操作人、审核状态明细表存每一件商品的编码、数量、成本价。调拨单则特殊一些需要在主表上同时存src_warehouse_id和dest_warehouse_id两个仓库字段明细表不需要再区分来源和目标。单号也有讲究。常见格式是“单据类型前缀 日期 当日序号”比如SO202506140001代表销售出库单。生成方式用Redis递增最省事$seq $redis-incr(bill:so: . date(Ymd)); $billNo SO . date(Ymd) . str_pad($seq, 4, 0, STR_PAD_LEFT);这段代码里Redis的INCR操作用在“日期单据类型”维度上保证同一天同一类型的单号不重复。str_pad把序号补成四位超过9999时自动扩到五位不会断号。使用Redis前要确认incr的key设置过期时间比如72小时避免长期占用内存。3.4 索引设计里容易忽略的细节索引设计上流水表最常用的查询是“某个仓库某个商品的进出记录”所以(warehouse_id, product_id, created_at)组合索引是必需的。单据明细表要建(bill_id, product_id)索引库存表已经有唯一键ula_wh_prod不再需要额外加索引。常见的设计误区有三个一是不写流水表只存当前库存二是把数量字段直接挂在商品表上三是调拨时直接改库存表数量没有成对流水。这些错误在业务初期看不出来数据量一上去、单据一多ERP数据没有跑通的原因分析做起来就非常痛苦因为根本没有可追溯的线索。4. 进出库业务逻辑实现PHP事务代码怎么写数据模型定了业务代码的核心就是一件事在同一个事务里更新库存表、写入流水表、更新单据状态。三件事必须同时成功或同时失败任何一步中途退出数据就会不一致。4.1 销售出库先锁行再写流水以销售出库为例最核心的方法是扣减指定仓库库存同时记录流水public function saleOut($warehouseId, $productId, $qty, $billNo) { $pdo-beginTransaction(); try { // 1. 锁定该仓库该商品的库存行 $sql SELECT quantity, locked_quantity FROM product_stock WHERE warehouse_id ? AND product_id ? FOR UPDATE; $stmt $pdo-prepare($sql); $stmt-execute([$warehouseId, $productId]); $stock $stmt-fetch(PDO::FETCH_ASSOC); if (!$stock || $stock[quantity] $qty) { throw new RuntimeException(库存不足); } // 2. 扣减库存 $sql UPDATE product_stock SET quantity quantity - ?, updated_at NOW() WHERE warehouse_id ? AND product_id ?; $pdo-prepare($sql)-execute([$qty, $warehouseId, $productId]); // 3. 写库存流水 $sql INSERT INTO stock_flow (flow_no, warehouse_id, product_id, bill_type, bill_no, change_type, change_qty, before_qty, after_qty, created_at) VALUES (?, ?, ?, SALE_OUT, ?, OUT, ?, ?, ?, NOW()); $pdo-prepare($sql)-execute([ $this-genFlowNo(), $warehouseId, $productId, $billNo, $qty, $stock[quantity], $stock[quantity] - $qty ]); // 4. 更新单据状态为已审核、已出库 $pdo-prepare(UPDATE stock_bill SET status DONE WHERE bill_no ?) -execute([$billNo]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; } }这段逻辑的顺序是固定的先SELECT FOR UPDATE锁定行再UPDATE扣减再INSERT流水最后更新单据。SELECT FOR UPDATE的目的不是读取而是让并发事务在这个仓库这个商品的库存行上排队。因为锁要等到commit或rollback才释放所以整个事务必须尽可能短循环里调用扣减、事务里做网络请求、事务里调用外部接口都是绝对不能做的事。流水里的before_qty取的是加锁后读到的quantityafter_qty是扣减后的结果。这样即使未来要反审核也能拿着流水把quantity加回去不需要猜当时的值。4.2 调拨入库与调拨出库成对流水怎么保证一致调拨是验证一个系统是不是真多仓的最佳试金石。一次调拨必须同时完成两个动作源仓库出库、目标仓库入库而且两个动作要在同一个事务里提交。public function transfer($fromWarehouse, $toWarehouse, $productId, $qty, $transferNo) { // 先按固定顺序锁定两个仓库的库存行防止死锁 $sql SELECT quantity FROM product_stock WHERE warehouse_id ? AND product_id ? FOR UPDATE; $first min($fromWarehouse, $toWarehouse); $second max($fromWarehouse, $toWarehouse); // 先锁第一个再锁第二个顺序永远一致 $pdo-prepare($sql)-execute([$first, $productId]); $pdo-prepare($sql)-execute([$second, $productId]); // 源仓库扣减、目标仓库增加各写一条流水 // 源仓库TRANSFER_OUTbefore_qty - after_qty减少 // 目标仓库TRANSFER_INbefore_qty - after_qty增加 // 两条流水的bill_no都指向同一个调拨单号 }调拨的难点不在SQL本身而在加锁顺序。两个仓同时往对方调拨时如果A事务先锁仓1再锁仓2B事务先锁仓2再锁仓1两边就会互相等待最终触发死锁。解法就是所有调拨都按固定的仓库ID顺序加锁先锁ID小的再锁ID大的。4.3 多仓库存查询与流水追溯SQL日常用得最多的两个查询一个是按仓看库存一个是按商品追溯流水。按商品汇总所有仓库的库存SELECT s.warehouse_id, w.name AS warehouse_name, s.product_id, p.name AS product_name, s.quantity, s.locked_quantity FROM product_stock s JOIN warehouse w ON w.id s.warehouse_id JOIN product p ON p.id s.product_id WHERE s.product_id 1001 ORDER BY s.warehouse_id;追查某个仓库某件商品的历史变动SELECT bill_no, bill_type, change_type, change_qty, before_qty, after_qty, created_at FROM stock_flow WHERE warehouse_id 1 AND product_id 1001 ORDER BY id DESC LIMIT 50;这两条SQL能覆盖“某个货现在在哪几个仓、每个仓多少、最近怎么变的”这类高频问题。ERP数据对不上时第一件事就是跑第二条SQL看看最近一笔流水的after_qty和库存表的quantity是否一致。4.4 批次与有效期要不要做如果商品还有批次和有效期管理的需求就不能只在product_stock上按product_id存一行而要把批次维度加进去。常见做法是库存表改成(warehouse_id, product_id, batch_no)三个字段的唯一键流水表里也补上batch_no列。批次的引入会让所有出入库逻辑多一个参数每次扣减时要按先进先出或指定批次分组处理。这块的复杂度比多仓库还高建议系统先跑稳多仓库再在流水表和库存表上平滑扩展不要第一版就全上。5. 多仓库并发超卖PHP里的三种扣减方案库存扣减在单机单用户时代不是问题但网页版ERP一旦有多个操作员同时确认出库或者对接了电商中台并发就会直接打在同一个仓库的同一件商品上。两个请求同时读到库存5件各自扣3件都判断成功最后库存变成2件实际卖了6件超卖就发生了。5.1 多仓系统比单仓更容易遇到超卖场景单仓库系统的并发面只在一个仓多仓库系统虽然分散了压力但热销商品往往集中在某个中心仓流量反而更加密集。再加上调拨、线上订单占用、线下批发开单同时对同一个仓操作库存竞争是常态。超卖的根源是“读后写”这个模式先查库存够不够再减库存。两个事务并发执行时读到的都是旧值后面的判断就全部失真。解决思路只有两条让“读到值”到“更新值”之间不允许别的请求插入或者把判断和扣减合并成一条原子操作。5.2 方案一SELECT ... FOR UPDATE 行锁第四章里的代码已经是这个方案。优点是实现直观、逻辑清晰完全基于数据库保证一致性缺点是所有并发请求都要等锁吞吐量受数据库连接数和事务时长限制。适用场景是中小ERP系统并发量每秒几十到几百完全够用。// 事务开始后执行行锁查询 $sql SELECT quantity FROM product_stock WHERE warehouse_id ? AND product_id ? FOR UPDATE; $pdo-prepare($sql)-execute([$warehouseId, $productId]); $stock $stmt-fetch();这里要注意FOR UPDATE只在事务内有效PDO开启事务后必须用同一个连接执行查询和更新。如果锁定的行不存在SELECT不会报错但要先判断结果不然后面的UPDATE也是空操作。行锁还可能引发死锁需要配合5.5的加锁顺序策略一起使用。5.3 方案二条件UPDATE原子扣减如果并发压力再大一点可以用一条UPDATE把判断和扣减合并$sql UPDATE product_stock SET quantity quantity - ? WHERE warehouse_id ? AND product_id ? AND quantity ?; $stmt $pdo-prepare($sql); $stmt-execute([$qty, $warehouseId, $productId, $qty]); if ($stmt-rowCount() 0) { throw new RuntimeException(库存不足或商品不存在); }这条语句利用了MySQL的原子性quantity ?这个条件在UPDATE执行时判断数据库行锁保证同一时刻只有一个事务能修改这行。affected rows为0就说明库存不够业务层直接抛异常。这个方案比FOR UPDATE少一次SELECT也不存在“读到旧值”的问题。代价是拿不到扣减前的库存量流水表里的before_qty需要额外处理。常见做法是先查一条不带锁的数据取增长前的值或者干脆改用5.4的Redis方案配合异步写流水。5.4 方案三Redis预扣减高并发场景下数据库行锁会成为瓶颈。可以把热销商品的库存提前加载到Redis里用DECRBY原子扣减$key stock:wh_{$warehouseId}:prod_{$productId}; $remain $redis-decrby($key, $qty); if ($remain 0) { $redis-incrby($key, $qty); // 回补 throw new RuntimeException(库存不足); }DECRBY是原子操作Redis单线程模型保证不会超卖。扣减成功后再异步把流水和数据库库存更新同步过去。这个方案的维护成本明显更高Redis宕机会丢库存数据Redis里的值和数据库值会短暂不一致需要定期校准任务。进销存系统的业务特征决定了它不太需要Redis这种预扣减方案因为单据量有限远达不到让MySQL成为瓶颈的程度。如果未来要对接电商平台的实时库存再考虑把Redis当成“预占层”数据库仍然是最终凭证。5.5 三种方案的对比与死锁预防方案一致性来源并发上限复杂度适用场景FOR UPDATE行锁数据库行锁中等低中小型ERP、后台手工开单条件UPDATE数据库原子更新较高低接口对接、自动化出库Redis预扣减Redis原子操作高高电商库存、高并发秒杀死锁是方案一最常见的事故。预防手段就是我在调拨里说的所有涉及多个仓库或多个商品的事务都按固定的顺序加锁。比如先锁warehouse_id较小的行再锁较大的涉及多个商品时先按product_id升序处理。死锁发生后MySQL会自动回滚其中一个事务业务层要捕获死锁异常并重试重试次数建议控制在三次以内。6. 老PHP源码改造与对账验证系统跑起来只是开始真正决定它能用多久的是对账能力。老进销存源码最常见的毛病不是功能缺失而是数据悄悄不一致没人发现。6.1 用 Docker 跑老PHP源码接手老PHP源码时第一件事是把它用Docker固定住运行环境。常见做法是把PHP 5.6或7.4连同老扩展一起打成一个镜像业务代码挂载进容器里避免“在我电脑上好好的到你服务器上就报错”。docker run -d -p 8080:80 \ -v /opt/web:/var/www/html \ -e MYSQL_HOSTmysql56 \ php:5.6-apache跑老代码要注意三个配置php.ini里short_open_tag要设为Off避免PHP短标签混用导致报错display_errors先打开方便排错但生产环境要关掉老代码用mysql_connect的还是确认下有没有被过期代码依赖。所有这些配置都应该写进Dockerfile或php.ini覆盖文件避免每次启动容器都手工改。6.2 用流水表和库存表做日结对账有流水表的系统每天凌晨可以跑一个对账脚本按仓库、商品汇总当天的入库和出库再和库存表的当前数量比对。以下SQL是日结对账的核心SELECT f.warehouse_id, f.product_id, SUM(CASE WHEN f.change_type IN THEN f.change_qty ELSE 0 END) AS total_in, SUM(CASE WHEN f.change_type OUT THEN f.change_qty ELSE 0 END) AS total_out FROM stock_flow f WHERE f.created_at 2025-06-14 00:00:00 AND f.created_at 2025-06-15 00:00:00 GROUP BY f.warehouse_id, f.product_id;把这段SQL的结果和“昨日库存 今日入库 - 今日出库”计算出的期望值做比对任何一个仓库或商品的差额不为零都说明当天存在漏记流水、单据重复审核或者被人工直接改了库存表。把对账脚本挂进crontab每天早上只处理差异清单绝大多数ERP数据对不上的问题都能在发生当天被定位到具体单据。本文还有配套的精品资源点击获取