尧图精选

ThinkPHP药店进销存系统开发:数据库设计、事务处理与PHP8兼容指南

🕒 发布时间:2026/9/14 15:15:19 📁 来源:尧图网络
简介基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8 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) ) ENGINEInnoDB DEFAULT CHARSETutf8; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8;销售单结构一致只是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入库明细关联drugsale_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, stockarray(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加stockquantity条件更新。直观对比写法并发安全代码复杂度适用情况select 后判断再 update不安全低演示、单机访问量极小setDec 不判断会变负数低不推荐where setDec 条件更新安全低推荐为什么推荐第三种UPDATE ... WHERE id? AND stockN在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_deleteThinkPHP 3.2还支持_after_deleteThinkPHP 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必须设为2rewrite模式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.htmlURL路径里的模块名被域名取代原来的链接全部要被替换。这个改动费时但很常见尤其当项目要拆前台和后台时。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版本再检查数据库驱动配置是否切换到了mysqliDB_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秒。对于毕业设计后台管理登录状态下也可以每次都用真实查询但加上这个缓存能在演示时显得更专业也能避免频繁查询带来的锁竞争。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →