简介:基于Thinkphp5.0框架开发的进销存客户管理系统源码,面向中小企业及PHP开发者,用于在线管理商品采购、销售、库存与客户资料,适合快速搭建可二次开发的轻量业务后台。压缩包共2000个文件,约15.13MB,以php、html、js、css等前后端文件为主,并包含png、gif等图片素材及sql数据库文件,安装时导入demo_360.sql并修改数据库配置即可运行。系统内置后台与前台分离的登录机制,后台默认账号admin,前台账号由后台分配,功能上覆盖商品、订单、库存、客户等核心管理模块,同时提供报表、权限等辅助能力。已有117人学习浏览,源码目录结构清晰,配合注释与配置示例,便于开发者理解Thinkphp5.0的路由、模型及模板用法,也可直接作为进销存项目的起点进行二次开发。
1. 一套能直接上线的ThinkPHP5.0进销存系统,先看清楚它的边界
很多小型企业处理进销存时,最头疼的不是没有功能,而是采购入库、销售出库和客户资料各记各的,月底一对账才发现库存跟账面差出一大截。这套基于ThinkPHP5.0的进销存客户管理系统,把商品、库存、订单、客户这几条线收拢到一套后台里,前台跑业务,后台做维护,登录入口和管理员入口分开,权限边界从一开始就划清楚。对正在选型的中小型销售业务来说,它比从零开发起一个新系统要省下至少一半工作量;对刚接触ThinkPHP5.0的开发者而言,它也是一个能完整拆开的实战案例,涵盖多模块划分、数据库设计、库存流水和订单联动。它的边界也很明确:不追求大而全的ERP能力,而是把采购到销售这条主链路做扎实,让你在二次开发时不用重新搭地基。
2. ThinkPHP5.0进销存系统的模块划分与数据建模
2.1 框架选型:为什么这类项目普遍使用TP5
进销存业务最怕的是把所有逻辑塞进一个控制器里,改成需求时无从下手。ThinkPHP5.0之所以在中型业务系统中出现频率高,是因为它明确了请求生命周期:URL通过路由解析到控制器,控制器调用模型层操作数据库,最后把数据交给视图渲染。这个单向流程和进销存里“单据触发数据变化”的场景天然匹配,比如一张采购单入库后,库存表和库存流水同时更新,逻辑可以收拢到一个服务类里,而不是散落在多个控制器。
技术栈上,TP5.0运行在PHP 5.6到7.2之间,使用PDO连接MySQL。相比后面的ThinkPHP6,TP5的目录结构更扁平,应用目录下模块划分直观,适合快速上手。这套源码保留了框架原始的application结构,打开后能一眼分清后台、前台和公共层。对于需要维护老项目或者接手既有系统的工程师来说,这种结构几乎没有学习成本。
2.2 后台、前台与公共层如何分工
打开源码根目录,最需要注意的不是thinkphp核心包,而是application下按模块分离的目录:
project_root/ ├── application/ # 应用目录 │ ├── admin/ # 后台管理模块 │ ├── member/ # 前台会员模块 │ ├── common/ # 公共模型和函数 │ ├── config.php # 应用基础配置 │ └── database.php # 数据库连接配置 ├── public_html/ # Web入口目录 │ ├── index.php # 单入口文件 │ └── static/ # 静态资源 ├── thinkphp/ # 框架核心目录 └── demo_360.sql # 数据库初始化备份这里有两个关键设计。第一,入口收敛到public_html,所有请求都通过index.php进入,避免用户直接访问到application/database.php这类敏感文件。第二,后台admin模块和前台member模块彼此独立,各自维护登录会话,后续做权限控制时可以在模块基类里统一加校验,不用在业务方法中重复判断。
common目录通常放一些跨模块的公共模型和辅助函数,比如订单流水号生成、状态码映射、金额格式化。这个分层直接决定了改需求时的代价:如果改动只涉及后台管理,程序员只需要在admin模块内调整;如果涉及库存变动规则,则优先改common下的模型或服务类,保证前台和后台调用的是同一套逻辑。
2.3 核心数据表结构与关联关系
进销存系统的数据核心不是某个表,而是“库存变动”如何被记录。很多初版系统只在goods表里放一个stock字段,销售时减一,采购时加一,看起来简单,但碰上退货、盘点、订单取消,数值会被改得不可追溯。这套系统的做法是建立独立的stock_log库存流水表,把每一次数量变化都记录在案,goods表里的stock只是用于列表展示的冗余字段。
主要表结构如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| goods | 商品主表 | goods_id, goods_name, category_id, stock, price |
| goods_category | 商品分类表 | cid, name, parent_id |
| order | 订单主表 | order_id, order_sn, user_id, total_amount, status |
| order_goods | 订单商品明细表 | id, order_id, goods_id, quantity, price |
| customer | 客户资料表 | customer_id, name, phone, address, level |
| stock_log | 库存变动日志表 | log_id, goods_id, type, quantity, before_stock, after_stock |
从关联关系上就能反推业务路径:商品挂在分类下,订单包含多条商品明细,客户下订单产生消费记录,而每一次订单确认或采购入库都会往stock_log里写一条变动日志。下面是库存日志表的建表语句,重点在quantity的方向性:
CREATE TABLE `stock_log` ( `log_id` int(11) NOT NULL AUTO_INCREMENT, `goods_id` int(11) NOT NULL COMMENT '商品ID', `type` tinyint(1) NOT NULL COMMENT '1采购入库 2销售出库 3盘点调整', `quantity` int(11) NOT NULL COMMENT '变动数量,正负表示方向', `before_stock` int(11) NOT NULL COMMENT '变动前库存', `after_stock` int(11) NOT NULL COMMENT '变动后库存', `operator` varchar(30) NOT NULL COMMENT '操作人或操作来源', `create_time` int(11) NOT NULL, PRIMARY KEY (`log_id`), KEY `idx_goods_id` (`goods_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;quantity用正负号表示库存方向,比单独维护一个direction字段更便于汇总:当你要查某个商品在一段时间内的净变动,直接SUM(quantity)就能得出结果。before_stock和after_stock看起来像是冗余,实际作用是排查问题时能直接看到某次操作前后的库存快照,不需要从头演算历史记录。商品主表和订单表在导入demo_360.sql后已经包含一定量的测试数据,可以先用这些数据验证系统流程,再根据需要清空重建。
3. 安装部署:从SQL导入、数据库配置到入口目录定向
3.1 环境准备与导入demo_360.sql
部署这套系统前,需要先确认服务器环境满足以下条件:PHP 5.6到7.2版本,开启PDO MySQL扩展和GD图形库,MySQL 5.7以上且支持utf8mb4字符集,Web服务器使用Nginx或Apache均可。PHP版本过高时,ThinkPHP5.0的某些写法会出现each函数被移除或count()参数类型变更导致的报错,所以不建议在PHP 7.4以上版本直接运行。
数据库初始化文件demo_360.sql位于项目根目录,它包含完整的表结构和测试数据。命令行导入是最稳定的方式,尤其是当SQL文件超过20MB时,phpMyAdmin会经常超时:
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS demo_360 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -u root -p demo_360 < demo_360.sql第一条命令创建数据库并明确指定字符集。第二条命令把SQL文件导入到demo_360库。导入时如果遇到Unknown collation相关报错,说明MySQL版本过低,需要把SQL文件里的utf8mb4_unicode_ci批量替换成utf8mb4_general_ci,或者升级数据库版本。导入完成后执行mysql -u root -p -e "USE demo_360; SHOW TABLES;",能看到goods、order、customer等表就说明成功。
提示:导入SQL前备份原始文件,防止错误操作需要重新初始化。
demo_360.sql里可能会带上演示环境的数据库前缀,如果前缀和配置文件不一致,后面登录时会报数据表不存在。
3.2 修改application/database.php配置
数据库连接信息集中在application/database.php,这是ThinkPHP5.0的标准配置文件。打开文件后,只需要修改以下几个字段:
return [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'demo_360', 'username' => 'root', 'password' => 'your_password', 'hostport' => '3306', 'charset' => 'utf8mb4', 'prefix' => 'tp_', ];type声明数据库类型,TP5底层通过PDO连接,不需要额外安装驱动。hostname填本机时使用127.0.0.1,如果数据库和Web不在同一台机器,则填数据库服务器的内网IP。database必须和你第一步创建的库名一致。username和password对应MySQL账号,线上环境不要使用root,建议单独创建账号并只授予demo_360库的权限。
charset使用utf8mb4,因为商品名称、客户备注里可能出现emoji或特殊符号,utf8mb4才能完整存储。prefix是表前缀,需要和SQL文件里实际使用的前缀保持一致。如果导入时表名就是tp_goods这样的形式,这里就是tp_;如果SQL里是goods不带前缀,这里必须留空。
改完配置后,先不要急着访问页面。在public_html下创建一个临时PHP文件,用PDO直接验证连接是否顺畅:
<?php $dsn = 'mysql:host=127.0.0.1;dbname=demo_360;charset=utf8mb4'; $pdo = new PDO($dsn, 'root', 'your_password'); $count = $pdo->query("SELECT COUNT(*) FROM goods")->fetchColumn(); echo "商品总数: " . $count . PHP_EOL;如果这个文件能正常输出商品总数,说明账号权限、数据库名、字符集都没有问题。如果输出的是SQLSTATE错误,重点检查密码前后是否有空格,以及database是否真的存在。测试完要立刻删除这个临时文件,避免暴露数据库信息。
3.3 设置Web入口为public_html
很多部署事故都源于把项目根目录直接作为Web根目录,导致application目录可以被浏览器访问。源码专门提供public_html作为入口目录,里面只有index.php和静态资源。Nginx环境下,server块应该这样配置:
server { listen 80; server_name your-domain.com; root /www/wwwroot/project/public_html; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } 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; } }root直接指向public_html,这样浏览器只能访问到入口文件和静态资源。location /里的rewrite把不存在的地址转给index.php?s=$1,TP5的路由参数通过s接收,因此/admin/这类URL才能正确映射到后台模块。如果是Apache,需要在public_html下放置.htaccess文件,内容通常由安装包自带,没有的话可以手动创建,作用同样是重写到index.php。
3.4 验证后台与前台登录
配置完成后访问http://your-domain.com/admin/,系统会跳转后台登录页。使用默认账号admin、密码admin888登录。登录成功后,左侧导航能看到商品管理、分类管理、库存管理、订单管理和客户管理入口,说明数据库连接和目录指向都正常。
接着访问/member/,这是前台会员登录页。注意前台的账号不是用admin登,需要在后台的客户或会员管理里手动创建一个用户。前台和管理员分开会话这一设计,既避免客户误入后台数据,也方便后期为不同角色配置独立权限。
上线前必须做两件事:第一,把admin888这个默认密码改掉,同时去后台打开验证码功能,源码中只要服务器安装了GD库,验证码开关就会生效;第二,手动访问.sql、.php等敏感文件路径,确认会被入口文件拦截,不会直接下载或显示源代码。
4. 核心业务实现:从商品、库存流水到订单联动
4.1 商品管理与分类的控制器逻辑
后台商品列表的控制器代码是整个系统的骨架之一,它展示了TP5的查询构造器如何组合搜索条件。典型实现如下:
namespace app\admin\controller; use app\common\model\Goods; use think\Request; class GoodsController extends BaseController { public function index(Request $request) { $keyword = $request->get('keyword', ''); $categoryId = $request->get('category_id', 0); $pageSize = $request->get('page_size', 15); $query = Goods::where('status', 1); if ($keyword !== '') { $query->whereLike('goods_name', "%{$keyword}%"); } if ($categoryId > 0) { $query->where('category_id', $categoryId); } $list = $query->paginate($pageSize); return view('index', ['list' => $list]); } }$request->get('keyword', '')从URL参数中读取搜索词,第二个参数是默认空字符串,保证用户第一次访问时不会报变量不存在的错误。whereLike执行的是模糊查询,TP5的查询构造器会过滤变量中的特殊字符,但如果你在模型里直接拼接SQL,仍然需要对keyword做转义。paginate是TP5自带的分页方法,返回的分页对象在模板渲染后就是完整的页码导航。
商品分类使用parent_id表示父子关系,顶级分类的parent_id为0。读取分类时一般递归查询,把分类层级拼成“/”隔开的完整路径,这样可以避免在前台展示商品时出现分类名称歧义。点击分类时往商品列表传递category_id,后台查询时用where('category_id', $categoryId)精确过滤。
4.2 库存变动:为什么不能直接加减商品表
进销存最容易出错的地方是库存更新。直接在goods表执行stock = stock - 1,在单用户测试时没问题,一旦出现订单取消、退货、手工盘点,库存数会变得无法解释。更好的做法是把“改变库存”这个动作封装成独立服务,所有业务入口都必须调用它来更新库存和流水表。
下面是一个典型的库存变动服务类:
namespace app\common\service; use app\common\model\Goods; use app\common\model\StockLog; class StockService { public static function change($goodsId, $type, $quantity, $operator) { $goods = Goods::find($goodsId); if (!$goods) { throw new \think\exception\ValidateException('商品不存在'); } $before = (int)$goods->stock; if ($type == 2 && $before < abs($quantity)) { throw new \think\exception\ValidateException('库存不足'); } $after = $before + (int)$quantity; Goods::where('goods_id', $goodsId)->update(['stock' => $after]); StockLog::insert([ 'goods_id' => $goodsId, 'type' => $type, 'quantity' => $quantity, 'before_stock' => $before, 'after_stock' => $after, 'operator' => $operator, 'create_time' => time(), ]); } }change方法先读取当前商品库存到$before。$type为2时表示出库,这时要判断库存是否足够,abs($quantity)把出库数量转成正数再比较,避免传入负数导致条件失效。$after的计算结果是当前库存加上传入的quantity,出库时调用方传入负数,采购时传入正数。
写入stock_log时记录变动前后的库存值,这是排查库存异常的关键:任何时候只要发现某商品账面数与实际不符,都能按create_time倒序查看是哪一笔操作造成的。实际项目里,所有涉及库存变动的入口,包括后台手工调拨、前台订单生成、退货入库,都必须调用StockService::change,不允许业务代码直接更新goods.stock字段。
4.3 订单生成:先扣库存还是先写订单
订单模块是整个系统业务流的高潮。一张订单生成时,需要同时完成三件事:写入订单主表、写入订单商品明细、扣减库存并记录流水。顺序设计得不对,容易出现“有订单但没扣库存”或“库存扣了但没订单”的中间状态。
典型的创建订单方法:
public function createOrder($userId, $goodsItems) { $orderSn = date('YmdHis') . mt_rand(1000, 9999); $totalAmount = 0; $orderId = Db::name('order')->insertGetId([ 'order_sn' => $orderSn, 'user_id' => $userId, 'total_amount' => 0, 'status' => 0, 'create_time' => time(), ]); foreach ($goodsItems as $item) { $totalAmount += $item['price'] * $item['quantity']; Db::name('order_goods')->insert([ 'order_id' => $orderId, 'goods_id' => $item['goods_id'], 'quantity' => $item['quantity'], 'price' => $item['price'], ]); StockService::change($item['goods_id'], 2, -$item['quantity'], 'user:' . $userId); } Db::name('order')->where('order_id', $orderId)->update([ 'total_amount' => $totalAmount, ]); return $orderId; }代码里先把订单主表插进去,拿到自增的$orderId,这样明细表才能关联到正确的订单。然后逐条插入商品明细,每插入一条立即调用StockService::change并传入负数数量。这一顺序很关键:如果库存不够,change会抛出异常,订单创建过程被打断,数据库事务可以在外层包裹,保证已经插入的主表记录回滚,避免出现半成品订单。
总金额在循环结束后再更新,是为了避免循环中途失败时主表金额和明细金额不一致。订单号使用date('YmdHis')加四位随机数,同一秒内并发量大时可能冲突,生产环境建议再拼接一个随机字符串或使用redis自增序列。客户管理和订单模块之间的联动则通过user_id实现,后台在客户筛选时可以通过user_id关联查询订单表,计算出该客户的历史累计消费和最近下单时间,用于分级管理。
5. 二次开发要点与部署期常见坑
5.1 用TP5查询构造器完成销售统计
进销存系统上线一段时间后,最常被要求的功能就是“近30天商品销售排行”。这类统计不需要写复杂的原生SQL,TP5的查询构造器支持直接join和聚合:
$rank = Db::name('order_goods') ->alias('og') ->join('goods g', 'og.goods_id = g.goods_id') ->where('og.create_time', 'between', [strtotime('-30 days'), time()]) ->field('g.goods_name, SUM(og.quantity) as total_quantity') ->group('og.goods_id') ->order('total_quantity', 'desc') ->limit(10) ->select();join关联goods表,是为了在结果里直接显示商品名称,避免查出数据后再循环补查商品表。where between使用时间戳数组,比在SQL里写DATE()函数能更高效地利用索引。group按商品维度聚合,SUM(og.quantity)统计销量,order降序排列,limit 10取前十个。这样的查询结果直接就是数组,模板里遍历即可。
5.2 后台权限拦截的基类统一处理
后台所有控制器都继承了admin模块的BaseController,所以登录校验不需要在每个控制器里重复写。在基类的initialize方法中判断会话即可:
public function initialize() { parent::initialize(); if (!session('admin_id')) { return $this->redirect('/admin/login/index'); } $this->adminId = session('admin_id'); }parent::initialize()必须放在第一行,否则框架的语言包、配置文件和模块数据可能还没有加载完,后面读取会话或权限时会出现非预期错误。后台登录成功后写入admin_id,前台的member模块则使用member_id作为session键名,两者不冲突。如果后续需要更细的角色权限,可以在admin_id之外再加role_id,在initialize里根据当前请求的控制器名和方法名做节点匹配,把不匹配的请求直接跳到无权限提示页。
5.3 数据库连接数过高和慢查询排查
进销存系统随着单据增多,最容易出现的是MySQL连接被打满和慢查询。先用EXPLAIN查看订单明细查询有没有走索引:
EXPLAIN SELECT * FROM order_goods WHERE order_id = 1024;如果type列是ALL,说明该查询正在全表扫描,需要在order_goods表的order_id字段上建索引。同样地,商品列表页如果频繁按分类和库存排序,可以建category_id和stock的复合索引。另一个常见问题是在模板循环里查询数据库,比如循环商品列表时对每个商品又发起一次客户查询,形成N+1查询。正确的做法是查询商品列表时把需要的关联字段一次性查出来,再用数组映射。
上线前把application/config.php里的调试模式关闭,把日志级别调整成error,否则日常访问都会记录到debug日志,几天就能把磁盘写满。部署完成后,可以专门用命令行压测一下/admin/和/member/接口的并发响应,观察PHP-FPM和MySQL的进程占用,再根据瓶颈决定是否启用Redis缓存和页面静态化。
本文还有配套的精品资源,点击获取