☰
多店进销存系统开发实战:PHP+MySQL实现库存管理与调拨
2026/10/13 12:42:52 网站建设 项目流程

1. 为什么我要写一套多店进销存,而不是直接用现成软件

1.1 同时管三家店,Excel 真的会让人崩溃

我是在 2022 年接手家里三个门店的生意时才彻底想明白一件事:单店进销存和连锁零售,完全是两种物种。

单店的时候,一个 Excel 文件就能搞定,每天下班花十分钟把进货、销售、库存录一下,月底对个账,日子过得去。但门店一多,问题立刻暴露:A 店卖完的货,B 店还有五箱,你翻开总库存表根本不知道货在哪个门店;客户要的东西 A 店缺货,B 店明明有,但你不知道,只能打电话一家一家问;最要命的是调货,A 店给 B 店调了 10 件商品,两边都记在微信里,月底一核对,A 店说自己转出了 12 件,B 店说只收到 8 件,谁也说服不了谁。

那时候我用三个 Excel 文件分别管三个店,再加上一个总表去汇总,每天光是把三家店的数据手工复制粘贴一遍就得花一个多小时,还经常出错。有一次月底盘点,某个 SKU 账面上 A 店多了 8 件、B 店少了 10 件,我怎么查都查不出来问题出在哪,最后只能一笔一笔翻纸质单据,花了一个周末才勉强把账捋平。

就是这么一段经历,让我动了自研的心思。

1.2 市面上的 SaaS 和单店软件,为什么总觉得别扭

也不是没试过现成的方案。我前后对比了不少市面上的进销存软件和 SaaS 产品,最后都没用下去,问题集中在几个点上:

  • 单店版软件不支持多门店独立核算。它能把库存管起来,但所有库存都在同一个池子里,没办法按门店区分库存余额,更没法做门店之间的调拨。
  • SaaS 订阅模式长期算下来不便宜。三个门店要买三个账号,加一个总部账号,一年费用小几千,对小店来说不是一笔小钱。而且数据都在别人服务器上,业务规则想改也改不动。
  • 报表和权限不够灵活。我想让每个店长只看到自己店的库存和销售,总部看全部数据,还要能统计每个门店的毛利,这些需求在很多入门级软件里根本做不到,或者需要额外加钱定制。

当然,我也清楚自研是有代价的:开发要时间,维护要精力,Bug 要自己扛。但对我来说,前期投入一两个月,换来回门店数据的绝对掌控和长期零订阅费,这笔账值得算。所以最终我决定自己写一套轻量级的多店进销存管理系统源码,跑了大半年,期间踩了不少坑,也迭代了几个版本,现在完全可以放心地拿出来分享。

我给自己定的核心目标就四点:

  1. 一个商品档案,多个门店共享,但每个门店的库存独立;
  2. 门店之间可以调拨,调拨全程留痕,且必须经过审批;
  3. 所有库存变动(进货、销售、调拨、盘点)都能追踪到来源;
  4. 部署简单,普通虚拟主机就能跑,不需要太高配置。

后面的所有设计,都是围绕这四点展开的。

2. 系统分层设计与技术栈,为什么这么选

2.1 整体结构并无花哨,但足够皮实

整个系统我采用的是最经典也最不容易出错的形态:PHP + MySQL + 传统网页渲染,没有前后端分离,没有微服务,没有 Redis,也不上 Docker。

这是什么级别的选择?说出来你可能觉得土,但这套代码我已经跑了 8 个月,三个门店同时在线操作,从没出现过性能瓶颈。原因很简单:进销存这种系统,并发量远远到不了互联网应用的水平,平时同时在线人数也就十几个人。技术选型最重要的是你熟不熟、维护起来痛不痛,而不是够不够前沿。

我见过不少朋友一上来就想用 Spring Cloud 搞一个微服务架构进销存,最后代码写得不少,部署环境却弄了三天,连本地都跑不起来。这种过度设计,对一个小型团队管理系统来说完全没必要。

2.2 技术栈选型:PHP 和 MySQL 的务实理由

这里说下我为什么选了 PHP 而不是其它语言,纯属个人踩坑后的结论:

  • PHP 的部署成本几乎为零。随便一台 Nginx 或者 Apache 服务器,配上 PHP-FPM 就能跑,不需要编译,不需要复杂的依赖配置。如果去用 Java,光环境配置和打包构建就够折腾一晚上。
  • PHP 原生代码对后期维护更友好。整个系统就几十个 PHP 文件,没有框架层嵌套,任何一个懂点 PHP 的人接手都能看懂。我之前用过一个开源 ERP,光依赖的 Composer 包就有上百个,升级一次全家桶,怎么挂的都不知道。
  • MySQL 够用且用得明白。多店库存的核心其实是事务和行锁,这些 MySQL 的 InnoDB 存储引擎天生就支持,配合 PHP 的 PDO 扩展就可以实现安全的数据变更。

前端我用的是一套 Bootstrap 后台模板,没有引任何大型前端框架。不是我不会写 Vue,而是因为这套系统的使用者是店长,很多人的浏览器还是老版本,后台页面要的是简单直接,加载快,不挑设备。表格、表单、弹窗这些交互,原生 jQuery 完全能搞定,何必给使用者增加额外负担。

2.3 多店数据模型的核心:store_id 贯穿一切

多店版本和单店版本最大的区别在于:所有业务数据表上,都必须有一个 store_id 字段,用来标记这笔数据属于哪个门店。

商品是公共档案,所有门店共享一份,但库存是每家门店各算各的。打个比方:商品就像医院里的药品目录,目录是全国统一的,但每个药房实际库存是独立存放的,A 药房有 100 盒,B 药房有 0 盒,不能因为总库存 100 盒就觉得大家都够用。

所以我在设计表结构时,把所有业务表都强制加了 store_id 字段。**查询时,业务代码必须用当前门店的 store_id 做过滤条件;只有总部角色才能跨门店查看所有数据。**这个设计听起来简单,却是整个系统没有乱套的关键。

如果哪一天不需要多店了,想改成单店版,只需要把过滤门店的条件去掉,系统就退化成一套单店进销存,改造成本极低。这也算是这套源码的一个额外优点。

3. 数据库表设计:分店库存怎么管才不乱

3.1 核心表结构:sku_stock 和 sku 主表

整个数据库我建了十几张表,但最核心的只有四张:商品表、门店表、库存表、库存流水表。

商品表存储商品的基本信息,我用的设计如下:

CREATE TABLE `sku` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `sku_code` varchar(50) NOT NULL COMMENT '商品编码/条形码', `name` varchar(200) NOT NULL COMMENT '商品名称', `spec` varchar(100) DEFAULT '' COMMENT '规格型号', `unit` varchar(20) DEFAULT '件' COMMENT '单位', `retail_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '零售价', `cost_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '最近进货成本价', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_code` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

门店表很简单,就是门店编码、名称、联系人、电话、状态。

最关键的是 sku_stock 库存表,这张表是整个系统的心脏:

CREATE TABLE `sku_stock` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `store_id` int unsigned NOT NULL COMMENT '门店ID', `sku_id` int unsigned NOT NULL COMMENT '商品SKU ID', `quantity` int NOT NULL DEFAULT 0 COMMENT '当前可用库存', `locked_quantity` int NOT NULL DEFAULT 0 COMMENT '锁定库存(调拨途中占用)', `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_store_sku` (`store_id`, `sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意这个唯一联合索引uk_store_sku (store_id, sku_id),它非常关键。库存表必须保证同一个门店、同一个商品只能有一行记录。否则数据一散,所有统计都会错乱。

locked_quantity字段是我后来加的。当 A 店给 B 店调拨一批商品,在 A 店手续还没办完之前,这批货不能被卖出去,所以我把它记为锁定库存,从可用库存中扣减出去,但物理位置还在 A 店仓库,等 B 店确认收货后,A 店库存彻底减少、B 店库存增加。

3.2 库存流水表:每一次变动都要留痕

如果你之前只是用 Excel 记库存,你肯定吃过口头对账的亏。进销存系统里,**库存永远是结果,流水过程才是真相。**我单独建了一张 stock_flow 表,记录每一次库存变动:

CREATE TABLE `stock_flow` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `store_id` int unsigned NOT NULL COMMENT '门店ID', `sku_id` int unsigned NOT NULL COMMENT 'SKU ID', `change_type` varchar(20) NOT NULL COMMENT 'purchase/sale/transfer_in/transfer_out/check', `change_qty` int NOT NULL COMMENT '变动数量,正数增加,负数减少', `before_qty` int NOT NULL DEFAULT 0 COMMENT '变动前库存', `after_qty` int NOT NULL DEFAULT 0 COMMENT '变动后库存', `ref_no` varchar(50) DEFAULT '' COMMENT '关联单号,如进货单号/销售单号', `remark` varchar(255) DEFAULT '', `created_by` int unsigned NOT NULL DEFAULT 0 COMMENT '操作人ID', `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_store_sku_time` (`store_id`, `sku_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

刚开始做这套系统时我是没有流水表的,结果就出现了前面说的 A 店和 B 店对不上账的问题。没有流水,你只能看到某个商品现在库存是几,但根本不知道它是怎么变成这个数的。加了流水表之后,任何库存异常都可以顺着时间倒查,是哪一笔进货、哪一单销售产生了影响,一目了然。

这里多提一句:流水里的before_qty和after_qty一定要基于真实的数据库变更前后取值,不要自己去用当前库存减一个数量算出来。否则一旦并发请求交叉,流水记录就会失真。

3.3 调拨单与盘点单的设计

调拨是门店系统的特色业务。我设计了 transfer_order 主表和 transfer_order_item 明细表,字段包括单号、调出门店、调入门店、状态、商品明细、数量、创建时间、审批时间、收货时间。

状态设计上用了一个四状态流程:

  • pending:待审批;
  • approved:已批准,等待调出门店发货;
  • shipped:已发货,等待调入门店收货;
  • done:已收货完成。

为什么不用简单的两步状态(发起、完成)?因为没有审批环节,店长之间随口一句就能调货,事后最容易扯皮。加了强制审批,总部可以统一把控哪些货能调、不能调,库存变动的责任人也非常清楚。

盘点表则独立于业务流水,它记录盘点的 SKU、账面数量、实盘数量、差异。盘点差异确认后,会生成一条 change_type 为 check 的库存流水,把实际库存修正为实盘数量。

4. 核心源码拆解:进货、销售、调拨三条链路

4.1 进货入库:必须事务、更新库存、写流水一气呵成

进货流程是整个系统最典型的多步操作。这里我用 PDO 事务包住三步操作:插入进货单、更新库存、写库存流水。

直接上源码核心部分:

public function createPurchaseOrder($storeId, $supplierId, $items, $operatorId) { try { $this->pdo->beginTransaction(); $orderNo = date('YmdHis') . mt_rand(1000, 9999); $stmt = $this->pdo->prepare("INSERT INTO purchase_order (order_no, store_id, supplier_id, total_amount, status, created_by, created_at) VALUES (?, ?, ?, ?, 'confirmed', ?, NOW())"); $totalAmount = 0; foreach ($items as $item) { $totalAmount += bcmul($item['quantity'], $item['cost_price'], 2); } $stmt->execute([$orderNo, $storeId, $supplierId, $totalAmount, $operatorId]); $orderId = $this->pdo->lastInsertId(); $insertItem = $this->pdo->prepare("INSERT INTO purchase_order_item (order_id, sku_id, quantity, cost_price) VALUES (?, ?, ?, ?)"); $updateStock = $this->pdo->prepare("UPDATE sku_stock SET quantity = quantity + ?, updated_at = NOW() WHERE store_id = ? AND sku_id = ?"); $insertFlow = $this->pdo->prepare("INSERT INTO stock_flow (store_id, sku_id, change_type, change_qty, before_qty, after_qty, ref_no, created_by, created_at) VALUES (?, ?, 'purchase', ?, ?, ?, ?, ?, NOW())"); // 需要先取得当前库存作为 before_qty,再更新库存,再计算 after_qty $getStock = $this->pdo->prepare("SELECT quantity FROM sku_stock WHERE store_id = ? AND sku_id = ? FOR UPDATE"); foreach ($items as $item) { $insertItem->execute([$orderId, $item['sku_id'], $item['quantity'], $item['cost_price']]); // 关键:锁住库存行,防止并发修改 $getStock->execute([$storeId, $item['sku_id']]); $currentQty = (int)$getStock->fetchColumn(); if (!$currentQty) { // 如果不存在,则插入初始行 $this->pdo->prepare("INSERT INTO sku_stock (store_id, sku_id, quantity, updated_at) VALUES (?, ?, ?, NOW())") ->execute([$storeId, $item['sku_id'], 0]); $currentQty = 0; } $beforeQty = $currentQty; $updateStock->execute([$item['quantity'], $storeId, $item['sku_id']]); $afterQty = $beforeQty + $item['quantity']; $insertFlow->execute([$storeId, $item['sku_id'], $item['quantity'], $beforeQty, $afterQty, $orderNo, $operatorId]); } $this->pdo->commit(); return $orderId; } catch (Exception $e) { $this->pdo->rollBack(); throw $e; } }

**这里的核心思想是:插入单据、修改库存、写流水三个步骤要么同时成功,要么同时失败。**如果只插入进货单但库存没加,财务和库存就对不上;如果只改库存没写流水,后续查账就成了无头案。所以三件事必须放在同一个数据库事务里。

很多人第一次写进销存会漏掉SELECT ... FOR UPDATE这一句,觉得更新前先 select 再 update,没有问题。但在高并发下,两个请求可能同时读到同一个旧库存值,然后各自加数量,导致最后结果比实际少了。FOR UPDATE是 InnoDB 最简单的行级锁,加上它才能保证"读库存+改库存"是原子操作。后面踩坑环节我还会详细讲。

4.2 销售出库:用条件更新防超卖

销售与进货方向相反,核心在于扣减库存时不能扣成负数。我把销售出库作为独立方法写,关键代码如下:

public function createSaleOrder($storeId, $customerId, $items, $operatorId) { try { $this->pdo->beginTransaction(); // 先扣减库存,扣减的同时利用 WHERE 条件做判断 $deductStock = $this->pdo->prepare("UPDATE sku_stock SET quantity = quantity - ?, updated_at = NOW() WHERE store_id = ? AND sku_id = ? AND quantity >= ?"); $selectFlow = $this->pdo->prepare("SELECT quantity FROM sku_stock WHERE store_id = ? AND sku_id = ? FOR UPDATE"); $insertFlow = $this->pdo->prepare("INSERT INTO stock_flow (store_id, sku_id, change_type, change_qty, before_qty, after_qty, ref_no, created_by, created_at) VALUES (?, ?, 'sale', ?, ?, ?, ?, ?, NOW())"); $orderNo = date('YmdHis') . mt_rand(1000, 9999); $saleOrderInsert = $this->pdo->prepare("INSERT INTO sale_order (order_no, store_id, customer_id, total_amount, created_by, created_at) VALUES (?, ?, ?, ?, ?, NOW())"); $totalAmount = 0; foreach ($items as $item) { $totalAmount += bcmul($item['quantity'], $item['sale_price'], 2); } $saleOrderInsert->execute([$orderNo, $storeId, $customerId, $totalAmount, $operatorId]); $orderId = $this->pdo->lastInsertId(); $saleItemInsert = $this->pdo->prepare("INSERT INTO sale_order_item (order_id, sku_id, quantity, sale_price) VALUES (?, ?, ?, ?)"); foreach ($items as $item) { // 关键:条件 UPDATE,只有库存足够时才更新 $rowCount = $deductStock->execute([$item['quantity'], $storeId, $item['sku_id'], $item['quantity']]); if ($rowCount < 1) { throw new Exception("商品 {$item['sku_id']} 库存不足,无法销售"); } $selectFlow->execute([$storeId, $item['sku_id']]); $afterQty = (int)$selectFlow->fetchColumn(); $beforeQty = $afterQty + $item['quantity']; $insertFlow->execute([$storeId, $item['sku_id'], -$item['quantity'], $beforeQty, $afterQty, $orderNo, $operatorId]); $saleItemInsert->execute([$orderId, $item['sku_id'], $item['quantity'], $item['sale_price']]); } $this->pdo->commit(); return $orderId; } catch (Exception $e) { $this->pdo->rollBack(); throw $e; } }

这里的WHERE quantity >= ?非常巧妙。数据库在执行 UPDATE 时,会对命中的行自动加锁,并且只有满足库存充足的条件才会更新成功。**如果两个订单同时抢最后一件库存,数据库会让其中一个 UPDATE 成功,另一个 UPDATE 影响行数为 0,从而触发异常,跳转到 catch 里回滚事务。**这就是防超卖最稳妥的做法,不需要额外加锁,也不需要在应用层做排队。

有人会用 select 查一次库存,判断够了再去 update,但中间那个时间窗口照样可能被别的请求穿插,所以条件更新 + 影响行数判断才是真正安全的方案。

4.3 门店调拨:冻结库存与两店联动的状态机

调拨相对复杂,因为涉及两个门店的库存变化,还要考虑审批流程。我的实现思路是:

  1. 门店 A 发起调拨单,指定商品和数量,提交后生成单号,状态为 pending;
  2. 总部审批通过后,状态改为 approved;
  3. 门店 A 发货时,执行"转出冻结":先把 A 店库存中的quantity减少、locked_quantity增加,代表这批货被锁住,不再对外售卖;
  4. 门店 B 确认收货时,再执行"真正转移":A 店的locked_quantity减少,B 店的quantity增加,同时写两条流水。

核心方法如下:

public function shipTransfer($transferOrderId, $operatorId) { // 业务前提:审批已通过,状态为 approved $this->pdo->beginTransaction(); $items = $this->getTransferOrderItems($transferOrderId); $lockStmt = $this->pdo->prepare("UPDATE sku_stock SET quantity = quantity - ?, locked_quantity = locked_quantity + ? WHERE store_id = ? AND sku_id = ? AND quantity >= ?"); $insertFlow = $this->pdo->prepare("INSERT INTO stock_flow (store_id, sku_id, change_type, change_qty, before_qty, after_qty, ref_no, created_by, created_at) VALUES (?, ?, 'transfer_lock', ?, ?, ?, ?, ?, NOW())"); // 省略更新调拨单状态为 shipped 的 SQL foreach ($items as $item) { $rowCount = $lockStmt->execute([$item['quantity'], $item['quantity'], $item['from_store'], $item['sku_id'], $item['quantity']]); if ($rowCount < 1) { throw new Exception("库存不足,无法发货"); } // 写 A 店的锁定流水 $selectQty = $this->pdo->prepare("SELECT quantity, locked_quantity FROM sku_stock WHERE store_id = ? AND sku_id = ? FOR UPDATE"); $selectQty->execute([$item['from_store'], $item['sku_id']]); $row = $selectQty->fetch(PDO::FETCH_ASSOC); $insertFlow->execute([$item['from_store'], $item['sku_id'], $row['quantity'], ($row['quantity'] + $item['quantity']), $row['quantity'], $transferOrderNo, $operatorId]); } $this->pdo->commit(); } public function receiveTransfer($transferOrderId, $operatorId) { // 业务前提:状态为 shipped $this->pdo->beginTransaction(); $items = $this->getTransferOrderItems($transferOrderId); $unlockStmt = $this->pdo->prepare("UPDATE sku_stock SET locked_quantity = locked_quantity - ? WHERE store_id = ? AND sku_id = ? AND locked_quantity >= ?"); $increaseStmt = $this->pdo->prepare("INSERT INTO sku_stock (store_id, sku_id, quantity, updated_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE quantity = quantity + ?"); // A 店解锁 foreach ($items as $item) { $unlockStmt->execute([$item['quantity'], $item['from_store'], $item['sku_id'], $item['quantity']]); $increaseStmt->execute([$item['to_store'], $item['sku_id'], $item['quantity'], $item['quantity']]); // 写B店入库流水 // 写A店解锁流水 } $this->pdo->commit(); }

调拨里我最想强调的一点是:**调拨中的货,既不能算在 A 店可用库存里,也不能算在 B 店可用库存里,它处于"在路上"的状态。**很多二次开发的人会忽略locked_quantity,直接 A 店减、B 店加,结果就是盘点时 A 店短了货,却不知道这批货还在路上。锁定量就是为了解决这个"悬空"问题。

5. 实测部署过程,以及我踩过的三个大坑

5.1 从开发环境到正式服务器的部署步骤

代码写完之后,真正把它部署到能用的服务器上,其实也就四步:

  1. 准备一台 PHP 7.4+ 和 MySQL 5.7+ 的服务器,我用的是一台 2核4G 的云主机,跑这套系统非常轻松;
  2. 把项目代码上传到网站目录,我放在/www/wwwroot/erp下面;
  3. 在 MySQL 里创建数据库,导入database.sql初始化表结构和默认管理员账号;
  4. 修改config/config.php,填上数据库地址、账号、密码。然后启动服务,访问首页。

这套系统没有使用 Composer 依赖,所以部署时不需要执行任何包管理命令。**这一点我特别看重,因为很多小店用户连命令行都不熟,让他们跑 composer install 就是劝退。**上传代码、改配置、导入数据库,三步走完,页面就能打开。这套流程我前后在三个环境里跑过,没有任何问题。

不过要提醒一下:生产环境一定不要把 config.php 里的数据库密码写成明文后随手丢进公共代码仓库。我在这里吃的亏后面会细说。

5.2 坑一:并发销售导致库存变成负数

这是我上线第一周就遇到的严重问题。当时店里搞促销,某款商品库存只剩 8 件,结果手机端和收银台同时卖出两单,一单 5 件、一单 6 件,理论上总共需要 11 件,最多只能成交 8 件。但系统最后显示库存变成了 2 件——而不是 0 或者负数,原因是我的代码里一开始只做了UPDATE sku_stock SET quantity = quantity - ?,完全没有WHERE quantity >= ?条件。

这样做的后果是:两个请求同时读到库存 8,第一个请求扣 5 变成 3,第二个请求扣 6 变成 2;实际上第二单是不应该成交的,因为库存根本不够。用户的钱都付了,货却发不出来,这才是最可怕的。

后来我改成刚才代码里的条件更新方案,又写了一个压测脚本模拟 50 个并发请求同时抢购,库存再也没有出现过负数或超卖。如果你现在要拿这套源码去用,请务必检查所有扣减库存的 SQL 是否有quantity >= ?这个条件,没有的话,赶紧补上。

5.3 坑二:库存流水里的 before_qty 和 after_qty 对不上

第二个坑出在流水记录上。一开始我是先更新库存,再 select 得到最新库存,然后直接把这个最新库存当作 after_qty 写进流水,before_qty 则用最新库存加上本次变动数来倒推。听起来没问题,但在并发场景下,两次查询之间可能间隔了别的请求,导致我拿到的 "最新库存" 已经是别人改过的值,before 和 after 完全对不上。

举个例子:A 请求扣减库存前库存是 10,扣了 3 后理论上 after 应该是 7,但它查询时发现库存已经变成 6 了,因为 B 请求又扣了 1。所以 A 的流水写成了 before=9、after=6,跟实际完全不符合。

正确的做法我在源码里体现了:**在同一个事务里,先 SELECT ... FOR UPDATE 锁住库存行,取得当前准确值,再执行 UPDATE,然后用锁内取得的当前值计算 before 和 after。**锁内读到的这个值,不会被别的请求穿插,因此流水才真实可信。

这个细节如果你不自己踩一遍,很难意识到有多重要。但当你发现库存对不上账的时候,往往已经积累了几十上百笔错误流水,一条条修正想死的心都有。

5.4 坑三:报表查询慢到页面超时

系统刚上线时,总部有一个"商品进销存汇总表",要按门店维度统计某个时间段内每个 SKU 的进货、销售、结存数量。最初这条 SQL 直接关联 sku、sku_stock、stock_flow,没有任何索引,结果数据量稍微一大,页面要转十几秒才能出来。店长急性子,等不了,直接关浏览器。

后来我做了两个优化,效果立竿见影:

  • 给 stock_flow 表加联合索引(store_id, sku_id, created_at),查询具体门店、具体商品的时间区间流水时走索引扫描,快非常多;
  • 把高频报表的统计维度固定为"按天",也就是在每天定时任务里预先算出每个门店、每个 SKU 的当天汇总,存到stock_daily_summary表里。前端报表直接查汇总表,秒开。

如果哪天你发现进销存的报表越来越慢,第一反应不要是加服务器配置,先检查 SQL 有没有走索引、能不能预聚合。进销存的数据增长是持续性的,不优化报表 SQL,三个月后必卡。

6. 源码怎么用,二次开发往哪个方向走

6.1 源码目录结构说明

整个项目结构非常清晰,我放出来供你参考:

erp/ ├── admin/ # 后台管理入口 │ ├── index.php │ ├── dashboard.php # 汇总仪表盘 │ ├── sku.php # 商品档案管理 │ ├── stock.php # 库存查询 │ ├── purchase.php # 进货管理 │ ├── sale.php # 销售管理 │ ├── transfer.php # 调拨管理 │ ├── report.php # 报表中心 │ └── ... ├── api/ # 供前端页面调用的接口 │ ├── purchase_api.php │ ├── sale_api.php │ ├── transfer_api.php │ └── ... ├── config/ │ └── config.php # 数据库配置 ├── includes/ │ ├── db.php # PDO 单例类 │ ├── auth.php # 登录鉴权 │ └── functions.php # 公共函数 ├── assets/ # CSS/JS/图片 ├── database.sql # 数据库初始化脚本 ├── README.md └── install/

目录核心在includes/db.php和api/文件夹里的各个接口文件。数据库连接类封装了 PDO 的预处理和事务方法,业务代码统一走接口响应,前台就负责渲染。

如果你拿到源码想直接跑,建议先导入database.sql,然后按 README 里的说明修改config.php。默认管理员账号是 admin,密码会在安装页面强制要求修改。

6.2 从多店版改单店版怎么改

如果你想把这套源码改成单店版本,其实非常简单:

  • 在登录角色那里去掉"门店"选择菜单;
  • 所有 SQL 里的store_id = ?条件直接删掉,sku_stock 表不需要 store_id 维度了;
  • 调拨管理模块可以直接隐藏,因为单店不存在调拨需求。

整个过程大概半小时可以完成,主要是在查询语句层面做减法。当然我更建议你保留多店逻辑,因为就算你现在只有一个店,保不齐明年就开分店了,这种扩展能力是白送的。

6.3 可以扩展的方向:会员、批次、多仓、独立权限

源码虽然已经能跑,但肯定有业务边界,你可以根据自己的情况做二次开发。我列几个常见方向:

  • 会员管理:加一张会员表,销售单关联会员ID,沉淀会员消费记录和积分;
  • 批次管理:sku_stock 扩展成按批次维度管理,一张库存表里需要加上 batch_no、生产日期、有效期,食品和化妆品行业基本都需要;
  • 多仓支持:把 store_id 抽象成"仓库"维度,门店就变成仓库的一种类型,这样总仓、门店仓、虚拟仓都能覆盖;
  • 细粒度权限:当前系统只有管理员、店长、收银员三种角色,你可以扩展成"数据权限 + 功能权限"两维控制,比如某个账号只能看销售单、不能看成本价。

这些扩展都不需要推翻现有表结构,因为你的核心表已经按多店维度拆开了,加字段和加表都比较自然。

7. 最后聊几句这套系统的边界和我的体会

这套系统我用了八个多月,三个门店的数据全部迁移进去了,从进货、销售到调拨、盘点,已经积累了两万多条库存流水。它帮我彻底告别了 Excel 对账时代,但我也不准备把它吹上天。它的定位就是中小型连锁门店的轻量解决方案,如果哪天业务复杂到需要供应链管理、采购计划、财务账套这些东西,还得换完整的 ERP 系统。

如果你要从零自己写一套,我给一个最重要的建议:**把事务和数据一致性放在第一位,功能排在第二位。**进销存系统的核心是"账实相符",哪怕你的界面丑陋一点、操作慢一点,只要库存和流水永远一致,用户就愿意用。反过来,哪怕你界面做得再漂亮,如果卖着卖着账对不上了,信任马上崩塌。

我希望这份源码能帮你省去我当初趟坑的时间。代码可能会因为不同 PHP 版本或 MySQL 版本有细微差异,但核心的设计思路——store_id 贯穿、库存流水留痕、事务锁保护并发——在任何语言、任何框架下都是通用的。哪怕你不完全照搬我的代码,能把这三个思路植入你自己的系统里,这个分享也算值了。

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

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

立即咨询