简介:基于ThinkPHP的药店药品进销存管理系统毕业设计项目,面向计算机相关专业的在校学生与初级开发者,适用于毕业设计、课程设计或项目初期立项演示,也能作为Web进销存业务的学习进阶素材。项目代码已经测试运行可用,核心功能覆盖药店进货、销售、库存与药品信息管理等常见业务模块,便于在此基础上二次开发以适配不同场景。压缩包内共498个文件,以321个PHP文件为主,辅以71个LESS样式、17个HTML页面及若干SQL、配置、字体与图片素材,整个包约1.47MB,目录结构较为完整,方便按模块阅读分析。目前已有143人学习下载。解压后即可得到可运行演示的完整工程,结合项目内说明文档可快速掌握部署与使用要点,对毕业设计答辩准备和代码能力提升都有实际帮助。
1. ThinkPHP药店药品进销存项目到底在做什么
药店药品进销存管理系统,听起来像课程设计,拿到手的往往又是一个压缩包,好像导入数据库、改下配置就能跑。但真正要答辩或者接手改需求时,最容易翻车的其实是三件小事:库存表结构能不能追溯、入库和销售写库顺序是否考虑并发、ThinkPHP老版本在新版PHP下还能不能正常启动。这篇文章会把这几件关键事按开发顺序拆开讲,从数据表设计到核心流程,再到关联删除、二级域名部署、PHP8兼容性检查,都按“我拿到项目后会怎么做”来描述。适合刚入门PHP的毕业生,也适合需要快速维护老ThinkPHP系统的开发。
2. 先把数据模型立住:ThinkPHP进销存的数据表和字段设计
拿到一个进销存项目压缩包,第一件事不是读控制器代码,而是看数据库表结构。因为这类系统八成业务逻辑都能从表关系看出来。常见的表包括:药品主表、供应商表、客户表、入库单及明细、销售单及明细、库存流水表、管理员表。下面按落地顺序来说。
2.1 药品、供应商、客户三张主表怎么建
药品主表是核心,字段除了名称、规格、价格,还要有唯一编码、当前库存和安全库存。供应商和客户表结构类似,但入库单关联供应商,销售单关联客户。我一般会这样建药品表:
CREATE TABLE `drug` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `drug_code` VARCHAR(30) NOT NULL COMMENT '药品编码', `drug_name` VARCHAR(100) NOT NULL COMMENT '药品名称', `spec` VARCHAR(50) DEFAULT '' COMMENT '规格', `unit` VARCHAR(10) DEFAULT '盒' COMMENT '单位', `purchase_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '进货价', `sale_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '零售价', `stock` INT NOT NULL DEFAULT 0 COMMENT '当前库存', `safety_stock` INT NOT NULL DEFAULT 0 COMMENT '安全库存', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0停用', `create_time` INT NOT NULL DEFAULT 0, UNIQUE KEY `uk_drug_code` (`drug_code`), KEY `idx_name` (`drug_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='药品主表';字段里的drug_code必须唯一,避免同一个药品被重复建档。价格用DECIMAL(10,2)而不是FLOAT,否则累计对账时会出现精度误差。stock属于冗余累计值,用来快速查询,真正的变动明细要写在流水表里。
供应商和客户就是普通台账表,可以合并成一张contact表再用type区分,但毕业设计的代码习惯是分开建,控制器更直观。关键在联表时外键字段名要统一,药品表里用supplier_id,销售单用customer_id,不能一处叫pid一处叫supplier_id。
2.2 入库单和销售单用“单头+明细”而不是直接改库存
如果业务只是执行一句“库存加10”,系统会很快变成糊涂账。进销存必须保留“谁、什么时候、从哪进的、进价多少、哪个药品”的记录。做法是把业务单据拆成主表和明细表,比如入库单:
CREATE TABLE `purchase_order` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(30) NOT NULL COMMENT '单号', `supplier_id` INT NOT NULL, `operator` VARCHAR(30) NOT NULL DEFAULT '' COMMENT '操作员', `total_amount` DECIMAL(10,2) DEFAULT 0.00, `create_time` INT NOT NULL, UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `purchase_order_detail` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `order_id` INT NOT NULL, `drug_id` INT NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `price` DECIMAL(10,2) DEFAULT 0.00, KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;销售单结构一致,只是supplier_id换成customer_id。这一设计能支持一张单子里多个药品,也能按单号对账。这里有个容易被忽略的点:明细表里的price是在入库时保存的价格快照。药品的purchase_price后续会调价,但不影响历史单据,所以detail表存自己的price是必要的。
库存如何被更新?建议在单据写入成功后,再调用一个统一的库存变动方法去回写drug.stock并插入stock_log,而不是在订单细节和主表里各写一套更新语句。这样未来加退货、盘点时,只需要改一个公共方法。
2.3 ThinkPHP关联查询最常用的几个JOIN场景
主表建好后,在ThinkPHP里做列表查询时最常用是join。以“入库单列表显示供应商名称”为例,ThinkPHP 3.2的写法是:
$list = M('purchase_order') ->alias('po') ->join('LEFT JOIN __SUPPLIER__ s ON po.supplier_id = s.id') ->field('po.order_no, po.total_amount, po.create_time, s.name as supplier_name') ->order('po.id DESC') ->select();__SUPPLIER__会被框架替换成实际表前缀加表名,换环境时不用改代码。order_no只从po取,s.name取供应商名称。如果订单表数据量大,给po.supplier_id加索引,否则JOIN会全表扫描。ThinkPHP 5的写法是Db::table(...)加alias和join,方法名相似,逻辑不用大改。
另外建议字段命名保持一致。时间字段统一用INT保存Unix时间戳,查询时统一用date()格式化。如果同时存在datetime和int,代码里到处是strtotime,后期维护会很痛苦。下面这张表是此类项目最常见的表清单:
| 表名 | 用途 | 关联关系 |
|---|---|---|
| drug | 药品主表 | 关联入库/销售明细 |
| supplier | 供应商 | 被入库单引用 |
| customer | 客户 | 被销售单引用 |
| purchase_order | 入库单头 | 一对多明细 |
| purchase_order_detail | 入库明细 | 关联drug |
| sale_order | 销售单头 | 一对多明细 |
| stock_log | 库存流水 | 每次变动一条 |
3. 用ThinkPHP把进销存的核心流程写出来
表模型建好后,重点就是两个动作:入库和销售。很多初学同学直接写M('drug')->setInc('stock', 10),这在单机测试没问题,但并发时会超卖或账不平。下面按实际项目顺序来写。
3.1 库存操作前先不急着写SQL,用事务和条件更新锁定数据
库存变动是所有进销存功能里最容易出错的点。我一般把“加库存”和“减库存”抽成一个独立方法,让入库、销售、退货都调用它。这样修改规则时只动一处。ThinkPHP 3.2使用PDO连接,事务操作很直接:
public function changeStock($drugId, $quantity, $type) { $model = D('Drug'); $model->startTrans(); try { if ($type == 'in') { $ok = $model->where(array('id' => $drugId))->setInc('stock', $quantity); } else { // 扣减库存时必须判断现有库存是否足够 $ok = $model->where(array('id' => $drugId, 'stock' => array('egt', $quantity))) ->setDec('stock', $quantity); } if (!$ok) { $model->rollback(); return false; } $model->table('__STOCK_LOG__')->add(array( 'drug_id' => $drugId, 'quantity' => $quantity, 'type' => $type, 'create_time' => time(), )); $model->commit(); return true; } catch (Exception $e) { $model->rollback(); return false; } }核心点是$model->where(array('id'=>$drugId, 'stock'=>array('egt',$quantity)))->setDec('stock', $quantity)。ThinkPHP会把egt翻译成>=,最终SQL是UPDATE drug SET stock = stock - N WHERE id = ? AND stock >= N。如果库存不够,影响行数是0,$ok为假,直接回滚。这个写法从根上避免“先读库存后写库存”的并发超卖问题,也省掉了显式SELECT ... FOR UPDATE。
setInc和setDec是ThinkPHP提供的自增自减方法,参数是字段名和步长。不建议用setField('stock', $newStock),因为并发场景下先查出来的新值可能已经过期。事务里的rollback只能回滚已执行的写操作,所以changeStock要放在外层事务中,不能单独开启自己的事务又调用方开启,两层事务会破坏回滚逻辑。
3.2 入库单生成:模型数据表和Session配合的完整流程
入库单比单纯改库存多了两层:先保存主表拿到单号,再循环明细写入。这里有个常见坑:主表插入后获得的order_id必须用于所有明细的order_id,在ThinkPHP 3.2中add()返回的就是自增ID。我一般这样组织:
public function addPurchaseOrder($post) { $orderModel = D('PurchaseOrder'); $orderModel->startTrans(); $orderId = $orderModel->add(array( 'order_no' => 'RK' . date('YmdHis') . mt_rand(1000, 9999), 'supplier_id' => (int)$post['supplier_id'], 'operator' => session('admin_name'), 'total_amount' => 0, 'create_time' => time(), )); if (!$orderId) { $orderModel->rollback(); return false; } $total = 0; foreach ($post['items'] as $item) { $amount = $item['quantity'] * $item['price']; $total += $amount; $orderModel->table('__PURCHASE_ORDER_DETAIL__')->add(array( 'order_id' => $orderId, 'drug_id' => $item['drug_id'], 'quantity' => $item['quantity'], 'price' => $item['price'], )); // 调用公共库存方法,注意这里不能用独立事务 $this->changeStock($item['drug_id'], $item['quantity'], 'in'); } $orderModel->where(array('id' => $orderId))->setField('total_amount', $total); $orderModel->commit(); return $orderId; }参数来源是I('post.')里的数据。$post['items']是二维数组,前端表单input的name要写成items[0][drug_id]、items[0][quantity]这种数组形式,后端才能正确接收到。operator从session里取,这要求入库操作必须登录后执行。如果controller里没有做权限校验,session('admin_name')可能为空,所以更稳妥的做法是在进入这个模板方法前先判断当前管理员是否有权限,然后把uid传入。
total_amount在前端计算还是后端计算?我建议后端计算。每次累加quantity * price,不要信任前端传的总价。上面的代码里add()返回的$orderId用于后续明细写入,但如果商品数量过多,循环里多次写库性能不算好,毕业设计场景下完全没问题。
3.3 销售扣减库存的三种常见写法,以及为什么推荐条件更新
销售模块至少有三种实现。第一种先查stock,判断够用再更新;第二种直接用setDec减,不加条件;第三种用where加stock>=quantity条件更新。直观对比:
| 写法 | 并发安全 | 代码复杂度 | 适用情况 |
|---|---|---|---|
| select 后判断再 update | 不安全 | 低 | 演示、单机访问量极小 |
| setDec 不判断 | 会变负数 | 低 | 不推荐 |
| where + setDec 条件更新 | 安全 | 低 | 推荐 |
为什么推荐第三种?UPDATE ... WHERE id=? AND stock>=N在InnoDB里会锁住该行,更新和判断合并成原子操作,两个请求同时进来也会串行执行。ThinkPHP的数组条件不会把值拼进SQL,天然防御注入:
$result = M('drug') ->where(array('id' => $drugId, 'stock' => array('egt', $salesQty))) ->setDec('stock', $salesQty); if (!$result) { // 库存不足,返回给前端明确提示 }这段代码要注意setDec返回的是影响行数,而不是剩余库存。如果$salesQty为0,条件变成stock >= 0,影响1行,业务上应该提前拦截。
4. ThinkPHP关联删除和二级域名配置在项目里的落地
数据库设计和核心流程之后,经常会被问到两个问题:删除主表数据时,关联的明细怎么办?部署时如果把后台挂在admin二级域名下,框架能不能自动切换模块?这两点分别对应ThinkPHP关联删除和ThinkPHP开启二级域名设置,也是答辩时的高频问点。
4.1 删除药品时如何把库存明细一起清掉
在ThinkPHP 3.2里,“关联删除”不是框架默认行为。直接删drug表记录,stock_log和purchase_order_detail里的drug_id就变成悬挂数据。最简单的方案是在药品模型里写删除前回调:
class DrugModel extends Model { protected function _before_delete($options, $pk) { $ids = is_array($pk) ? $pk : (array)$pk; M('stock_log')->where(array('drug_id' => array('in', $ids)))->delete(); } }_before_delete是ThinkPHP 3.2模型删除前自动调用的方法,参数$pk是主键。框架传入的可能是一个数组,代表批量删除,这里统一转成数组再拼IN条件。注意“删除”与“停用”的区别。进销存系统里销售和采购明细必须保留,否则历史对不上,所以更推荐用status字段把药品置为停用;只有清理临时数据时才物理删除。
4.2 用模型事件做级联删除,比在控制器里重复写更省心
除了_before_delete,ThinkPHP 3.2还支持_after_delete,ThinkPHP 5/6里则叫模型事件。无论哪种,目的都是把关联逻辑收敛到模型层。比如删除供应商时,可以把该供应商的未审核入库单一起处理:
class SupplierModel extends Model { protected function _before_delete($options, $pk) { $ids = is_array($pk) ? $pk : (array)$pk; $orderIds = M('purchase_order') ->where(array('supplier_id' => array('in', $ids))) ->getField('id', true); if ($orderIds) { M('purchase_order') ->where(array('id' => array('in', $orderIds))) ->setField('status', -1); // 标记为作废 } } }这里没有物理删除订单,而是把状态改成-1表示作废。逻辑说明:删除供应商前先查出关联订单ID,再更新这些订单的状态。比在每个控制器里手写一遍查询和更新更统一。注意如果订单已经入库,作废订单时必须同时回退库存,否则会凭空多出库存,这一步需要结合上一章的changeStock方法做事务补偿。
4.3 部署时把ThinkPHP站点放到二级域名下的配置
ThinkPHP 3.2中开启二级域名设置,就是让admin.yourshop.com自动对应Admin模块,api.yourshop.com对应Api模块。在Application/Common/Conf/config.php里配置:
'URL_MODEL' => 2, 'APP_SUB_DOMAIN_DEPLOY' => 1, 'APP_SUB_DOMAIN_RULES' => array( 'admin' => 'Admin', 'api' => 'Api', ),URL_MODEL必须设为2(rewrite模式);APP_SUB_DOMAIN_DEPLOY开启后,框架会从请求Host解析子域名并映射模块。同时Nginx需要支持泛子域名解析:
server { server_name ~^(?<sub>.+)\.yourshop\.com$; root /var/www/html; index index.php; location / { try_files $uri $uri/ /index.php?s=$uri&$args; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }.yourshop.com的DNS需要配置泛解析*.yourshop.com到服务器IP。本地测试可以在hosts里临时写几行:127.0.0.1 admin.yourshop.com。开启二级域名后,后台入口从http://localhost/index.php/Admin/Login/index.html变成http://admin.yourshop.com/Login/index.html,URL路径里的模块名被域名取代,原来的链接全部要被替换。这个改动费时但很常见,尤其当项目要拆前台和后台时。
5. 搬迁老项目必查:ThinkPHP 3.2在PHP8下最崩的几个函数
最后处理这类压缩包最常见的两个问题:作者当初用ThinkPHP 3.2在PHP 5.6下开发,现在服务器默认PHP 8.0,打开就是白屏;另外老版本有几个公开漏洞,不修就上线容易被人扫。
5.1 PHP8下老项目最崩的几个函数
ThinkPHP 3.2核心代码有多个PHP5时代的函数在PHP8中已被移除,比如each()、create_function()、mysql_*系列扩展。如果页面报Call to undefined function each(),可以先在phpinfo里确认当前PHP版本,再检查数据库驱动配置是否切换到了mysqli:
'DB_DRIVER' => 'mysqli',配置在config.php的数据库数组里。ThinkPHP 3.2使用mysqli驱动时,核心的字段缓存逻辑还有可能触发each()。最简单稳妥的做法是先降级到PHP 7.4把项目跑通,再逐步处理兼容性。如果必须用PHP 8,要手动把核心Think\Db\Driver.class.php中用到each()的循环改写成foreach;改框架核心前一定先备份,并开启APP_DEBUG看具体报错行。
5.2 ThinkPHP漏洞的常规修复位点
老版本ThinkPHP公开漏洞集中在两个地方:一是部分SQL查询允许直接拼接输入,导致注入;二是不安全的exp表达式被利用。多数毕业设计项目做好两件事就能挡住多数扫描工具:
return array( 'DEFAULT_FILTER' => 'htmlspecialchars,trim', 'URL_ROUTER_ON' => false, );配置DEFAULT_FILTER后,I('get.xxx')取到的值默认会过一遍htmlspecialchars和trim。同时所有查询都使用数组条件,不手写字符串条件:
// 推荐 $where = array('drug_code' => I('get.code')); M('drug')->where($where)->find(); // 避免 M('drug')->where('drug_code = "' . I('get.code') . '"')->find();还有一点,上线前APP_DEBUG必须设为false,否则框架会把数据库表名、SQL条件等敏感信息输出到页面。对老版本框架,不用的后台模块直接删除或禁用,减少攻击面。
5.3 用数据缓存给库存预警加一层防护
针对进销存的首页“低于安全库存药品”查询,每次刷新都会扫描整张drug表,数据量上来后会有压力。可以用ThinkPHP自带的S()方法缓存30秒:
$warning = S('stock_warning'); if ($warning === false) { $warning = M('drug') ->where(array('status' => 1, '_string' => 'stock <= safety_stock')) ->select(); S('stock_warning', $warning, 30); }S()第一个参数是缓存键名,第二个参数是要缓存的数据,第三个是有效期秒数。要注意缓存失效时机:每次入库或销售操作后应执行S('stock_warning', null),否则预警会滞后最长30秒。对于毕业设计,后台管理登录状态下也可以每次都用真实查询,但加上这个缓存能在演示时显得更专业,也能避免频繁查询带来的锁竞争。
本文还有配套的精品资源,点击获取