简介:一套基于ThinkPHP框架的药店药品进销存管理系统完整源码,面向PHP开发者、毕业设计学生以及需要搭建药品管理后台的运维人员,帮助读者从实际项目理解MVC分层设计、关系型数据库建模和药品进销存业务流程。包内以PHP业务逻辑代码为主,同时包含LESS/CSS前端样式、HTML模板、JS交互脚本、SQL数据库脚本和YAML等配置文件。源码覆盖药品信息维护、进货管理、销售管理、库存预警、会员积分和报表分析等核心功能,并融入SQL注入防御、XSS防护、角色权限控制、公共函数封装以及缓存优化等工程实践。压缩包共497个文件,大小约4.68MB,目录结构按功能模块划分,检索与部署较为方便。已有191人学习/下载,适合用其快速搭建功能原型、支撑课程设计或作为二次开发基线,从中可以直观获取一套可运行的进销存系统实现思路。
1. 药店药品进销存不是普通仓库,ThinkPHP 源码包该先看什么
药店进销存和普通商品进销存最大的差异,在于每一盒药都要能追溯到“哪个批号、什么时候到期”。同样的阿莫西林,不同批次进价不同、效期不同,销售时如果只按药品编码减总库存,半年后盘点对不上是小事,被药监检查问“近效期药品怎么处理”才是大事。所以这套基于 ThinkPHP 的药店进销存源码案例,核心不是把增删改查写完,而是把“批号 + 效期 + 流水”这条线串起来。
ThinkPHP 适合做这件事:MVC 分层清晰,文档和二手资料多,中小门店根本不需要上微服务,一台主机加 MySQL 就够了。即使是新手接包,拿到 zip 后也能按 public/index.php、database 下的 SQL、部署说明的顺序跑起来。这套系统面对的使用者是收银员和店长,主要路径是采购入库、验收批次、销售出库、近效期预警、每日毛利。下面按这个路径展开,不绕开事务、不回避并发,给你一套能照着改的实现。
2. 数据模型设计:药品表、批次库存表和流水表怎么建
2.1 主数据、批次库存、流水分别承担什么
药店系统里,药品的基本信息是一份,库存数量不是一份。因为同一个药品会有多个批号,每个批号的进价和效期不同。用一张 drug 表存药品主数据、一张 drug_batch 表存批次库存、一张 stock_log 表存每次数量变化,是最稳的分工。
| 表名 | 职责 | 关键字段 | 常见误解 |
|---|---|---|---|
| drug | 药品主数据 | code, name, specification, manufacturer | 误把库存数量写在这里 |
| drug_batch | 批次库存 | batch_no, expiry_date, quantity, purchase_price | 误把批次写进 drug 的扩展字段 |
| stock_log | 库存流水 | drug_id, batch_id, type, quantity, before_qty, after_qty, ref_type | 不记录 before/after,无法审计 |
设计原则是:drug 表不存 stock 字段,即使列表页想显示库存,也是在查询器里 join 一个合计出来。这样库存永远能从 drug_batch 和 stock_log 两条路径交叉验证,盘点差异也能定位到具体单据。
2.2 批次表和流水表的建表 SQL
下面这段 SQL 是这套系统的地基,关键点已经写进注释:
CREATE TABLE `drug_batch` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `drug_id` int unsigned NOT NULL COMMENT '药品ID', `batch_no` varchar(40) NOT NULL COMMENT '生产批号', `expiry_date` date NOT NULL COMMENT '有效期至', `quantity` decimal(12,3) NOT NULL DEFAULT '0.000' COMMENT '当前库存', `purchase_price` decimal(10,2) NOT NULL COMMENT '批次进价', `sale_price` decimal(10,2) NOT NULL COMMENT '零售价', `store_id` int unsigned NOT NULL DEFAULT '0' COMMENT '门店ID', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 2近效期 3过期', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_drug_batch` (`drug_id`,`batch_no`,`expiry_date`,`store_id`), KEY `idx_expiry` (`expiry_date`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批次库存表'; CREATE TABLE `stock_log` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `drug_id` int unsigned NOT NULL, `batch_id` int unsigned NOT NULL, `type` tinyint NOT NULL COMMENT '1采购入库 2销售出库 3退货入库 4报损 5盘点', `quantity` decimal(12,3) NOT NULL COMMENT '正数入、负数出', `before_qty` decimal(12,3) NOT NULL, `after_qty` decimal(12,3) NOT NULL, `ref_type` varchar(20) NOT NULL COMMENT '来源单据:purchase_order/sale_order', `ref_id` int unsigned NOT NULL, `operator_id` int unsigned NOT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_batch` (`batch_id`), KEY `idx_ref` (`ref_type`,`ref_id`), KEY `idx_drug_time` (`drug_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';为什么 quantity 用 decimal(12,3) 而不是 int?药店的拆零销售很常见,一整盒和拆开一片都要能表达;int 只能存整数,float 又会出现 0.30000000000000004 这样的误差。decimal 由数据库保证精确,PHP 端读出来是字符串,计算时用 bcadd/bcsub,展示时再转成浮点数。
批次表上的唯一键是(drug_id,batch_no,expiry_date,store_id),含义是同一个门店里,同一个药品、同批号、同效期只能有一行库存。这个唯一键是“采购入库合并批次”的基础,也防止重复录入导致数量分裂。status 字段和 expiry_date 都要建索引,因为效期预警命令每天按它们批量更新。
2.3 ThinkPHP 模型里把批次顺序固化
在 ThinkPHP 6 中,批次按效期升序应该定义在模型关联里,而不是在每个控制器里 order。代码:
namespace app\model; use think\Model; class DrugBatch extends Model { protected $name = 'drug_batch'; protected $autoWriteTimestamp = true; public function drug() { return $this->belongsTo(Drug::class, 'drug_id'); } }namespace app\model; use think\Model; class Drug extends Model { protected $name = 'drug'; public function batches() { return $this->hasMany(DrugBatch::class, 'drug_id') ->order('expiry_date asc, id asc'); } }这里的 order 是先进先出顺序,id asc 是为了处理同一批号、不同次入库时主键小的那行先出。如果以后想改成“效期晚的先出”,只改这一处关联,所有调用方统一生效。总库存从批次表聚合:DrugBatch::where('drug_id', $id)->sum('quantity'),不要再去读药品表上冗余的库存字段。列表页做查询时,用with(['batches'])或关联聚合预载入,避免 N+1 查询。
注意:不要在 controller 里混用Db::name()和模型查询。ThinkPHP 6 的 Db 门面和模型在很多地方行为不一致,统一走模型可以拿到类型转换、自动时间戳和关联预载入。否则同一个模块里一会返回模型对象、一会返回数组,类型判断会把后续代码写乱。
3. 采购入库与销售出库的完整流程:事务、锁和先进先出
3.1 采购入库:先查批次再合并数量
采购入库拆成两步:先写 purchase_order 和 purchase_order_item,然后执行入库方法。inbound 在事务内对每个明细查一次 drug_batch,存在就加数量,不存在就新建:
public function inbound(int $orderId): void { $order = PurchaseOrder::find($orderId); if (!$order || $order->status != 0) { throw new \RuntimeException('采购单不存在或已入库'); } Db::startTrans(); try { $items = PurchaseOrderItem::where('order_id', $orderId)->select(); foreach ($items as $item) { $batch = DrugBatch::where('drug_id', $item->drug_id) ->where('batch_no', $item->batch_no) ->where('expiry_date', $item->expiry_date) ->where('store_id', $order->store_id) ->lock(true) ->find(); if ($batch) { $before = $batch->quantity; $batch->quantity = bcadd((string)$batch->quantity, (string)$item->quantity, 3); $batch->save(); $after = $batch->quantity; } else { $batch = new DrugBatch(); $batch->drug_id = $item->drug_id; $batch->batch_no = $item->batch_no; $batch->expiry_date = $item->expiry_date; $batch->store_id = $order->store_id; $batch->purchase_price = $item->purchase_price; $batch->sale_price = $item->sale_price; $batch->quantity = $item->quantity; $batch->save(); $before = '0.000'; $after = $batch->quantity; } StockLog::create([ 'drug_id' => $item->drug_id, 'batch_id' => $batch->id, 'type' => 1, 'quantity' => $item->quantity, 'before_qty' => $before, 'after_qty' => $after, 'ref_type' => 'purchase_order', 'ref_id' => $orderId, 'operator_id' => $this->uid, ]); } $order->status = 1; $order->inbound_time = date('Y-m-d H:i:s'); $order->save(); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } }这里bcadd是 PHP 的任意精度加函数,三个参数分别是被加数、加数、小数位,用字符串传入避免浮点误差。lock(true)会生成SELECT ... FOR UPDATE,把同一批次行锁住,防止两个管理员同时给同一采购单点“入库”,或者采购入库和销售出库同时改同一批数量。before/after 写入流水后,库存差异可以从流水重算,这是审计的基础。如果不想先 select 再 update,也可以用$batch->where('id', $id)->inc('quantity', $item->quantity)->update(),但那样拿不到 before 值,流水表会缺一半信息,所以一般不用。
3.2 销售出库按先进先出拆批次
销售出库时,一个销售明细可能要扣多个批次的货。sell 方法的思路是:把该药品所有正库存批次按效期升序取出来,先判断总库存是否够,不够就整单失败;够就循环扣减。总库存判断和逐批扣减必须在同一事务里,否则判断完成后另一个收银台再操作,库存就超卖了:
public function sell(int $saleOrderId): void { Db::startTrans(); try { $items = SaleOrderItem::where('order_id', $saleOrderId)->select(); foreach ($items as $item) { $need = (string)$item->quantity; $batches = DrugBatch::where('drug_id', $item->drug_id) ->where('store_id', $item->store_id) ->where('quantity', '>', 0) ->order('expiry_date asc, id asc') ->lock(true) ->select(); if (bccomp($need, (string)$batches->sum('quantity'), 3) > 0) { throw new \RuntimeException($item->drug_name . ' 库存不足'); } $costAmount = '0.00'; foreach ($batches as $batch) { if (bccomp($need, '0', 3) <= 0) { break; } $deduct = bccomp((string)$batch->quantity, $need, 3) >= 0 ? $need : (string)$batch->quantity; $before = $batch->quantity; $batch->quantity = bcsub((string)$batch->quantity, $deduct, 3); $batch->save(); StockLog::create([ 'drug_id' => $item->drug_id, 'batch_id' => $batch->id, 'type' => 2, 'quantity' => '-' . $deduct, 'before_qty' => $before, 'after_qty' => $batch->quantity, 'ref_type' => 'sale_order', 'ref_id' => $saleOrderId, 'operator_id' => $this->uid, ]); $costAmount = bcadd( $costAmount, bcmul($deduct, (string)$batch->purchase_price, 2), 2 ); $need = bcsub($need, $deduct, 3); } SaleOrderItem::where('id', $item->id) ->update(['cost_amount' => $costAmount]); } SaleOrder::where('id', $saleOrderId)->update([ 'status' => 1, 'sale_time' => date('Y-m-d H:i:s'), ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } }说明:$need是销售数量,扣到 0 后退出循环;$deduct从当前批次扣减数量,取“need”和“batch.quantity”中较小的。bcmul($deduct, $batch->purchase_price, 2)计算这个批次的销售成本,累加后写入 sale_order_item 的 cost_amount,供毛利报表直接使用。
这段代码里的锁顺序是按 expiry_date 排序的,理论上多个收银台同时卖同一药品时,会按效期顺序逐个加锁。如果担心死锁,可以在查询里统一补一个order('id asc'),让所有事务的加锁顺序尽量一致,降低互相等待的概率。
3.3 拆零换算和负库存是主要边界
药店经常遇到“买 0.5 盒”的拆零单。decimal(12,3) 可以保小数运算,但界面要统一显示单位。建议加一个 unit 字段,如“盒/瓶/片”,拆零系数单独维护,销售数量永远以基础单位为准。
| 字段 | 示例 | 说明 |
|---|---|---|
| unit | 盒/瓶/片 | 销售界面显示单位 |
| conversion | 1 盒 = 10 片 | 拆零换算系数 |
| base_quantity | 0.5 盒 | 实际库存与流水都存基础单位 |
比如 1 盒 = 10 片,销售界面输入 5 片,后台先换算成 0.5 盒。这个换算放到 service 层的统一方法里,不要在控制器里写,否则每个接口来一遍除法,迟早有漏网。负库存必须禁止。库存不足就整单失败,不要允许店员先卖后补单。上面的总库存判断加整单事务已经能挡住大部分场景;如果出现极端并发,数据库层还可以给 drug_batch 加CHECK (quantity >= 0),MySQL 8.0.16 以上才会真正生效,旧版本只能靠应用层保证。
4. 效期预警、毛利报表和看板缓存的实现细节
4.1 效期预警用命令行任务更新批次状态
药店每天要看到哪些药 90 天内到期,实现方式是在 drug_batch 的 status 字段上做定时更新,前台页面只查状态。命令类代码:
namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\console\input\Option; use app\model\DrugBatch; class ExpiryCheck extends Command { protected function configure() { $this->setName('pharmacy:expiry-check') ->addOption('days', null, Option::VALUE_OPTIONAL, '预警天数', 90); } protected function execute(Input $input, Output $output) { $days = (int)$input->getOption('days'); $today = date('Y-m-d'); $threshold = date('Y-m-d', strtotime("+{$days} days")); DrugBatch::where('expiry_date', '<', $today) ->update(['status' => 3]); DrugBatch::where('expiry_date', '>=', $today) ->where('expiry_date', '<=', $threshold) ->where('status', '<>', 3) ->update(['status' => 2]); $output->writeln("expiry check done, threshold: {$threshold}"); } }命令行运行:
php think pharmacy:expiry-check --days 90days 参数表示“未来多少天内算近效期”,可根据门店周转改成 30 或 60。命令只更新状态,不主动发通知;Web 端轮询或企业微信机器人另做。定时任务配置:
0 2 * * * cd /var/www/pharmacy && php think pharmacy:expiry-check --days 90 >> runtime/expiry.log 2>&1runtime 目录需要可写,否则 crontab 一直失败但页面看不出来。药品量大会扫表,所以 status 和 expiry_date 上要有索引,前面建表 SQL 已经加上了。
4.2 毛利必须锁定销售时刻的批次成本
销售出库时,除了扣批次库存,还要把成本写进 sale_order_item 的 cost_amount。成本不是药品档案里的“默认成本”,而是先进先出扣减的多个批次进价的加权结果。这样才能追溯历史毛利,不受后续批次价格变化影响。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 从批次表实时取成本 | 结构简单,不冗余 | 批次价格一变历史报表就变 |
| 销售明细冗余 cost_amount | 报表稳定、可追溯 | 退货时需按原明细还原 |
| 统一用移动平均成本 | 计算简单 | 多批次进价差异大时毛利失真 |
上面销售代码里已经累加了 cost_amount,这里补一个日报表 SQL:
SELECT d.id, d.name, SUM(o.quantity) AS sale_qty, SUM(o.amount) AS sale_amount, SUM(o.cost_amount) AS cost_amount, SUM(o.amount - o.cost_amount) AS gross_profit FROM sale_order_item o JOIN drug d ON d.id = o.drug_id JOIN sale_order so ON so.id = o.order_id WHERE so.status = 1 AND so.sale_time >= :start AND so.sale_time < :end GROUP BY d.id, d.name ORDER BY gross_profit DESC分组字段用 d.id 而不是 d.name,因为不同厂家可能有同名药;order by 用聚合别名 gross_profit,在 MySQL 里可以直接排。sale_time 必须有索引,否则大月份数据量下这条 SQL 会把销售单表全扫一遍。
4.3 首页看板用 Cache 减少重复 SQL
看板要显示今日销售额、今日订单数、近效期药品数。每次刷新都执行七八条聚合 SQL 没必要,常见做法是缓存 5 分钟:
$storeId = request()->param('store_id', 0); $cacheKey = "report:daily:{$storeId}:" . date('Ymd'); $report = Cache::get($cacheKey); if (!$report) { $report = SaleOrderService::dailyStats($storeId, date('Y-m-d')); Cache::set($cacheKey, $report, 300); }缓存 key 必须包含门店 ID 和日期,否则两个门店的看板会互相串数据。入库、销售完成后,主动删除当天该门店的缓存:
Cache::delete("report:daily:{$storeId}:" . date('Ymd'));如果不删,看板最多滞后 5 分钟;门店营业高峰期 300 秒没问题,连锁总部要求实时大屏时可以把过期时间改成 30 秒,或直接改用定时任务预聚合。注意 ThinkPHP 6 的 Cache 驱动默认是文件缓存,多台服务器负载均衡时文件缓存不共享,需要换成 Redis 并统一 key 前缀。
5. 从 zip 到上线:目录检查、伪静态和打包交付
5.1 解压后先检查这三个地方
拿到基于ThinkPHP药店药品进销存管理系统源码案例设计.zip后,不要急着上传配置数据库,先看三个位置:
| 检查项 | 位置 | 缺失时的影响 |
|---|---|---|
| 阅读说明 | README.md 或 doc/ | 数据库账号约定全靠猜 |
| 建表脚本 | database/*.sql | 没有表结构,代码一直报表不存在 |
| 环境配置模板 | .env.example | 真实配置容易被打包带出去 |
如果 zip 里没有 .env.example,可以先复制一份 .env 再手动改数据库连接。生产环境务必把 APP_DEBUG 设为 false,并关闭 think 的 trace 页面,否则报错信息会暴露数据库表和字段名。
5.2 Nginx 伪静态和 PHP 版本兼容
ThinkPHP 6 的 URL 要配伪静态才能去除入口文件。Nginx 配置:
server { listen 80; server_name pharmacy.example.com; root /var/www/pharmacy/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php(.*)$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_index index.php; fastcgi_split_path_info ^(.+\.php)(.*)$; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } }root 必须指向 public 目录,避免用户直接访问 application 下的控制器文件。fastcgi_pass 的 socket 路径要跟本机 PHP-FPM 版本一致,比如 PHP 8.1 就是 php8.1-fpm.sock。如果源码包是老 ThinkPHP 3.2 写的,在 PHP 8.x 上大概率会报 Class 'mysql' not found,短期方案是切 PHP 7.4,长期方案是把数据库操作迁到新版框架的 Db 类,不要试图在旧框架里逐个补函数。
部署后先打两个请求做自检:
curl -I http://127.0.0.1/purchase/create curl -s http://127.0.0.1/api/stock/search?keyword=阿莫西林 | head -c 500返回 404 时先看 runtime/log 下的日志,再查 Nginx error.log。伪静态最常见的坑是 rewrite 规则被注释掉,或者 location 里 fastcgi_param 没写 SCRIPT_FILENAME,导致所有 PHP 文件都下载而不执行。
5.3 交付源码 zip 的打包技巧
给客户交付源码包时,必须排除敏感文件。一个干净的打包命令:
cd /var/www && zip -r pharmacy_src_v1.0.zip pharmacy \ -x "pharmacy/.env" \ -x "pharmacy/runtime/log/*" \ -x "pharmacy/runtime/cache/*" \ -x "pharmacy/public/uploads/*.tmp" \ -x "pharmacy/.git/*"-x 后面是排除规则,路径相对于打包命令所在目录。.env 绝不能打进去,里面是数据库密码;runtime 缓存会带着旧路由,客户第一次部署就直接报错。同时保留 database/init.sql 并在 README 里写明首次安装导入:
mysql -u pharmacy_user -p pharmacy < database/init.sql最后执行php think list,如果能列出 pharmacy:expiry-check 等命令,说明框架加载正常;再打开首页确认登录页能正常显示,这套 zip 才算真正交付。
本文还有配套的精品资源,点击获取