ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ThinkPHP药店进销存实战:批次效期、库存并发与对账方案

ThinkPHP药店进销存实战:批次效期、库存并发与对账方案 简介一份基于ThinkPHP的药店药品进销存管理系统毕业设计项目面向计算机相关专业在校生、教师及企业开发者适用于毕业设计、课程设计和项目初期演示。系统涵盖药品入库、出库、库存盘点、供应商管理、销售统计等核心业务模块代码经过测试运行稳定可作为二次开发的基础框架。压缩包共498个文件其中包含321个PHP核心逻辑文件、71个LESS样式文件、17个HTML页面、5个TPL模板和1个SQL数据库脚本整体大小仅1.47MB便于快速下载与部署。目录结构清晰项目配置文件、样式资源与业务代码分离方便按需查阅和修改。目前已有143人学习下载适合具有一定Web开发基础、希望快速搭建进销存管理系统的读者参考。下载后通过项目说明文件可快速了解环境配置与启动流程尤其适合需要完成类似选题毕业设计的学生直接使用或在此基础上扩展功能。1. 药店进销存管理系统用 ThinkPHP 做难点在批号效期与账目一致一个药店进销存系统表面是采购、销售、库存三组增删改查换 ThinkPHP 实现时真正卡人的是两件事同一种药多个批号、不同效期库存按谁记两台收银台并发卖同一批药库存会不会扣成负数。前者是数据模型问题后者是并发一致性问题都适合用 ThinkPHP 自带的事务、ORM 和查询构造器讲清楚。界面两三天能跑起来但批次、效期、流水对得上账系统才扛得住演示和答辩。下面按建模、写单、查询并发、打包交付四段推进代码以 ThinkPHP 6/8 为准第 3 章说明 ThinkPHP 3.2 老版本和 PHP 8 的兼容差异。分批扣减、软删除、流水审计这套做法对进销存和医药流通项目同样适用。2. 药品数据模型批次、效期、流水的三表设计2.1 药店商品表为什么不能照搬商城商品表普通商城的商品表是「一种商品一行库存数字」药品在其上多出四样东西批准文号、生产厂家、通用名与商品名的对应关系以及最要紧的批次与效期。同一款阿莫西林两个批次的采购价不同、有效期不同销售时必须优先出效期更近的批次所以库存不能只按药品记总数必须按「药品编号 批次号」记账。数据建模我一般拆四层drug 放药品主数据通用名、商品名、规格、厂家、批准文号、处方药标志都在这张表stock 放批次库存一条记录表示「某药某批次还剩多少、效期到哪天」采购单和销售单各自拆主单与明细两张表单据是业务证据stock_log 流水表记录每一次数量变化只增不改。这四层是后面所有查询、对账、审计的基础比急着写控制器重要得多。2.2 库存表与流水表各管什么库存表存当前值流水表存每一次变化二者必须分开。理由很简单库存是被扣减、被修正的状态今天是 10明天可能是 7流水是事件谁在什么时间因为哪张单子动了哪个批次永远不改。盘点、报损、退货都写成流水系统就随时能解释清当前库存是怎么来的。这是审计逻辑也是 GSP 追溯在系统层面的通用形态答辩时把这层讲清楚比多写十个页面有用。2.3 建表 SQL 与关键字段说明以下 SQL 去掉冗余字段保留进销存核心的四张表CREATE TABLE ph_drug ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, generic_name VARCHAR(100) NOT NULL COMMENT 通用名, trade_name VARCHAR(100) NOT NULL DEFAULT COMMENT 商品名, spec VARCHAR(50) NOT NULL DEFAULT COMMENT 规格, manufacturer VARCHAR(200) NOT NULL DEFAULT COMMENT 生产厂家, approval_no VARCHAR(64) NOT NULL DEFAULT COMMENT 批准文号, is_rx TINYINT NOT NULL DEFAULT 0 COMMENT 1处方 0OTC, min_stock INT NOT NULL DEFAULT 0 COMMENT 低库存预警线, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品主数据; CREATE TABLE ph_stock ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, drug_id INT UNSIGNED NOT NULL, batch_no VARCHAR(64) NOT NULL COMMENT 生产批次号, expire_date DATE NOT NULL COMMENT 有效期至, qty INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 本批次采购价, UNIQUE KEY uk_drug_batch (drug_id, batch_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT批次库存; CREATE TABLE ph_stock_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, drug_id INT UNSIGNED NOT NULL, batch_no VARCHAR(64) NOT NULL, type VARCHAR(20) NOT NULL COMMENT purchase_in/sale_out/stocktake/damage, qty INT NOT NULL COMMENT 入库正数出库负数, biz_type VARCHAR(20) NOT NULL DEFAULT COMMENT 来源单据类型 purchase/sale, biz_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 来源单据id, remark VARCHAR(255) NOT NULL DEFAULT , created_at DATETIME DEFAULT NULL, KEY idx_drug_batch (drug_id, batch_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水;字段取舍上有三个约定值得抄对象关键约定理由ph_drug.min_stock预警线放主数据同一药品所有批次共用一条预警线改一处全局生效ph_stock 唯一键uk_drug_batch(drug_id, batch_no)数据库层面保证同一批次不会出现两条库存记录库存数量 qtyINT 且符号含义统一流水里正入负出对账 SQL 只需一行 SUM数量字段用 INT 不用 DECIMAL因为药品按盒、瓶计数拆零场景在单据明细里用拆零单位字段表示不要把小数混进主数量字段。批号用 VARCHAR(64) 是因为实际生产批号长度不固定上游系统里可能带前缀符号预留空间比掐死长度省事。2.4 ThinkPHP 关联删除在单据表上的禁忌ThinkPHP 文档里的关联删除示例通常是「删用户时把订单一起删掉」这个模式不能带进进销存。采购单、销售单是审计证据删掉一张采购单会连带删除明细和库存流水账目就断了对不上账时没有任何线索可查。业务系统的正确姿势是主数据和单据一律软删除流水永远物理保留。use think\Model; use think\model\concern\SoftDelete; use think\model\relation\HasMany; class PurchaseOrder extends Model { use SoftDelete; protected $deleteTime deleted_at; public function items(): HasMany { return $this-hasMany(PurchaseItem::class, order_id); } protected static function booted(): void { static::deleting(function ($order) { // 软删除主单时明细同步归档库存与流水不受影响 $order-items-each-delete(); }); } }deleting 事件在软删除时同样会触发明细的 deleted_at 和主单一起落库软删除只改状态、不动库存账目的历史证据链保持完整。真正要撤销一笔入库正确做法是红冲另开一张负数入库单或退货单把库存和流水都走一遍正常流程而不是直接删单。答辩时能讲出「为什么不能用关联删除删单据」比写满 CRUD 页面更能体现业务理解。3. ThinkPHP 实现入库出库一个事务把单据、库存、流水同时落库3.1 版本与运行环境ThinkPHP 6/8 和 PHP 8 怎么配先定版本再写代码。新开的毕业设计项目直接装 ThinkPHP 6 或 8只有维护老系统才碰 ThinkPHP 3.2。这里有个高频坑ThinkPHP 3.2 的数据库驱动依赖 PHP 5 时代的 mysql 扩展拿到 PHP 8 环境会直接报Call to undefined function mysql_connect()双引号字符串拼接变量一类旧写法也会逐个炸掉。网上有各种 3.2 兼容 PHP 8 的补丁包但补丁只解决报错解决不了旧代码里已废弃的写法新项目没有理由从 3.2 起步。版本最低 PHP适合场景ThinkPHP 3.2PHP 5.3PHP 8 需打补丁老系统维护毕业设计不建议ThinkPHP 6PHP 7.2.5教程最多、资料最好搜ThinkPHP 8PHP 8.0新项目语法更现代安装用 Composer 一行命令composer create-project topthink/think pharmacy。数据库连接配置在 config/database.php核心参数如下return [ default mysql, connections [ mysql [ type mysql, hostname env(DB_HOST, 127.0.0.1), database env(DB_NAME, pharmacy), username env(DB_USER, root), password env(DB_PASS, ), hostport env(DB_PORT, 3306), charset utf8mb4, prefix ph_, fields_strict true, ], ], ];三个参数说明prefix 建议设成 ph_多套系统共存一个 MySQL 实例时不串表fields_strict 保持 true写入的字段在表里不存在时直接抛异常开发期能立刻暴露拼写错误hostname、password 从 .env 读取而不是写死在配置文件里交付 zip 时只留脱敏的 .env.example这点第 5 章的打包清单还会提到。提示ThinkPHP 3.2 项目想跑在 PHP 8 上常见的迁移路径是先升级到 ThinkPHP 5.1 再升 6而不是原地打补丁。每一步的数据库模型代码差异不大但能顺带清掉一批历史债务。3.2 入库写单事务闭包里完成五件事入库流程是「建采购单 → 写明细 → 库存累加 → 写流水 → 回填金额」五件事要么全成功要么全失败必须包在同一个事务里。ThinkPHP 的Db::transaction闭包写法比手写 startTrans/commit/rollback 少两个出错点闭包内抛异常自动回滚不用自己记状态use think\facade\Db; use think\facade\Validate; public function store() { $data request()-post(); $validate Validate::rule([ supplier_id require|integer, items require|array|min:1, items.*.drug_id require|integer, items.*.batch_no require|max:64, items.*.expire_date require|date, items.*.qty require|integer|gt:0, items.*.price require|float|egt:0, ]); if (!$validate-check($data)) { return json([code 1, msg $validate-getError()]); } $orderId Db::transaction(function () use ($data) { $order PurchaseOrder::create([ order_no PO . date(YmdHis) . random_int(1000, 9999), supplier_id $data[supplier_id], ]); $total 0.00; foreach ($data[items] as $line) { $lineTotal bcmul((string)$line[qty], (string)$line[price], 2); $total bcadd((string)$total, $lineTotal, 2); PurchaseItem::create([ order_id $order-id, drug_id $line[drug_id], batch_no $line[batch_no], expire_date $line[expire_date], qty $line[qty], price $line[price], ]); // 同批次存在则累加不存在则新建批次库存 $stock Stock::where(drug_id, $line[drug_id]) -where(batch_no, $line[batch_no]) -find(); if ($stock) { $stock-qty $line[qty]; $stock-save(); } else { Stock::create([ drug_id $line[drug_id], batch_no $line[batch_no], expire_date $line[expire_date], qty $line[qty], purchase_price $line[price], ]); } StockLog::create([ drug_id $line[drug_id], batch_no $line[batch_no], type purchase_in, qty $line[qty], biz_type purchase, biz_id $order-id, ]); } $order-total $total; $order-save(); return $order-id; }); return json([code 0, data $orderId]); }几个容易被忽略的参数细节批次累加用的是先查后改两个并发入库同时插入同一个新批次时唯一键 uk_drug_batch 会兜底后提交的插入直接冲突报错整个事务回滚用户重提即可bcmul 和 bcadd 是 PHP 的 BC Math 高精度函数金额计算必须用它而不是 float 相乘float 的 0.10.2 不等于 0.3 这种精度问题在做合计时一定会暴露流水表 qty 记正数代表入库出库场景记负数这样第 5 章的对账 SQL 才能只用一行 SUM 完成核对。验证器用 items.*.qty 这种通配规则直接校验二维数组ThinkPHP 从 5.1 开始支持比手写 foreach 判断干净。3.3 出库选批次效期优先的 FEFO 扣减出库和入库最大的区别在选哪个批次。药店行业默认按 FEFOFirst Expire First Out先到期先出效期近的批次必须优先卖防止药品压在库里过期。出库代码的核心是先按 expire_date 排序选出批次再逐批扣减public function deduct(int $saleOrderId, array $saleLines): void { foreach ($saleLines as $line) { $remaining $line[qty]; // FEFO效期近的排前面同效期按 id 保证先入先出 $batches Stock::where(drug_id, $line[drug_id]) -where(qty, , 0) -order(expire_date asc, id asc) -select(); foreach ($batches as $batch) { if ($remaining 0) { break; } $take min($remaining, $batch-qty); // where 里带 qty take同一瞬间被另一笔扣掉时这里失败 $affected Db::name(stock) -where(id, $batch-id) -where(qty, , $take) -dec(qty, $take) -update(); if ($affected 0) { throw new \RuntimeException(批次 {$batch-batch_no} 库存已变化请重试); } StockLog::create([ drug_id $line[drug_id], batch_no $batch-batch_no, type sale_out, qty -$take, biz_type sale, biz_id $saleOrderId, ]); $remaining - $take; } if ($remaining 0) { throw new \RuntimeException(药品 {$line[drug_id]} 库存不足); } } }这个方法的两个设计点第一扣减用 dec 配合where(qty, , $take)是原子操作两个事务同时执行时只有一个能改成功另一个 affected 为 0 直接抛异常天然防超卖第二每一批扣多少都写一条流水批号和数量一一对应以后查「这批药卖给谁了」有据可依。deduct 方法必须在调用方的事务里执行销售单、明细、库存扣减、流水就是同一个原子操作任何一步失败全部回滚。排序读配合原子更新的方案在收银台并发下足够稳悲观锁的取舍放到第 4 章讲。3.4 盘点与报损修正也走流水盘点差异和报损不需要单独建表它们是库存修正的两种 type。盘点算出「实盘数 - 账面数」的差异差异为正写正数流水为负写负数流水type 统一记 stocktakeremark 里写盘点单号报损同理记 damage。这样库存表永远只有累加和扣减两条路径对所有业务动作一视同仁第 5 章的对账脚本也只需处理一套符号规则。4. 基于 ThinkPHP 的库存查询、预警与并发扣减4.1 组合筛选用查询构造器叠加 where 条件库存列表页最常见的形态是「关键词 批次 效期范围 低库存」四个筛选条件自由组合查询构造器用 alias、join 和条件追加一次拼完use think\facade\Db; use think\facade\Request; $query Db::name(stock)-alias(s) -join(ph_drug d, d.id s.drug_id) -field(s.id, s.drug_id, s.batch_no, s.expire_date, s.qty, d.generic_name, d.trade_name, d.spec, d.min_stock); $keyword Request::param(keyword, ); if ($keyword ! ) { $query-where(function ($q) use ($keyword) { // 竖线分隔字段等价于 OR但走参数绑定 $q-whereLike(d.generic_name|d.trade_name|d.approval_no, %$keyword%); }); } $batchNo Request::param(batch_no, ); if ($batchNo ! ) { $query-where(s.batch_no, $batchNo); } $days (int)Request::param(expire_days, 0); if ($days 0) { $query-where(s.expire_date, between, [date(Y-m-d), date(Y-m-d, strtotime({$days} days))]); } if (Request::param(low_stock, 0) 1) { $query-where(s.qty, , Db::raw(d.min_stock)); } $list $query-paginate([ list_rows 15, query Request::get(), // 筛选条件带进翻页链接 ]);whereLike 支持竖线分隔多字段模糊匹配等价于 OR 拼接但走预处理参数绑定比手拼字符串安全low_stock 条件里Db::raw(d.min_stock)表示 s.qty 和同一条记录上的 d.min_stock 直接比较不要先把 min_stock 查出来再到 PHP 里比那样每页数据多读一次库paginate 的 query 参数最容易被漏掉——不带它翻到第 2 页筛选条件全部丢失。4.2 效期预警是个查询问题不必上定时任务近效期、已过期、低库存三类预警本质是三段 where 条件不需要消息队列也不需要常驻进程。药店系统的复杂版会配 cron 每天跑提醒毕业设计里把统计放到首页接口每次登录或刷新时算一遍就够了public function dashboard() { $today date(Y-m-d); $nearExpireDays config(pharmacy.near_expire_days, 90); $data [ nearly Stock::where(expire_date, between, [$today, date(Y-m-d, strtotime({$nearExpireDays} day))])-count(), expired Stock::where(expire_date, , $today)-count(), low Stock::alias(s) -join(ph_drug d, d.id s.drug_id) -where(s.qty, , Db::raw(d.min_stock)) -count(), ]; return json($data); }预警天数通过config(pharmacy.near_expire_days)读配置而不是写死 90改动阈值只改配置这是给评审老师看「参数可配置」的最直观案例。已过期批次不仅要预警还要在出库代码里加拦截FEFO 排序时把expire_date today的批次排除掉否则近效期药永远卖不出去。两个判断配合才是完整的效期管控。4.3 并发扣减悲观锁、乐观锁还是原子更新收银台并发扣减有三种常见写法对应不同代价方案实现优点缺点适用场景乐观锁查时带 version更新 where version旧值无锁、吞吐高要写重试逻辑盘点、改价悲观锁SELECT ... FOR UPDATE 再改思路直观长事务持锁易死锁后台审核、人工核对原子更新UPDATE ... WHERE qty N无读锁、防超卖仍需先读批次排序收银台高频出库ThinkPHP 的悲观锁写法是Stock::where(id, $id)-lock(true)-find()lock(true) 在 InnoDB 事务内翻译成 FOR UPDATE。它有两个使用前提必须在事务内调用事务提交后锁才释放锁定的行尽量少、持锁时间尽量短。出库这种要按效期排序选多个批次的场景悲观锁很容易变成「事务 A 锁了批次 1 想锁批次 2事务 B 锁了批次 2 想锁批次 1」的互相等待死锁日志查起来非常费时间。注意lock(true) 只在事务内生效事务外调用不会报错但锁立即失效写出来的代码是「看起来加了锁实际没锁」。第 3 章的 deduct 方法用的是先排序读批次、再逐批原子更新本质是乐观策略读时不加锁更新时靠qty take判断更新失败就抛异常让用户重试。这个方案在收银台读多写少的场景下吞吐最好一条 SQL 完成判断和扣减不会因为长时间持锁拖垮药品列表页。要做好的只是异常提示affected 为 0 时返回「库存已变化请刷新后重试」不要静默吞掉。4.4 查询接口的安全习惯参数绑定与白名单排序进销存项目查询条件多SQL 注入的高发点反而不在 where 上。where 走查询构造器数组和 whereLike 是安全的参数会被预处理真正危险的是把外部输入直接拼进 order 排序order 子句不走预处理。常见做法是白名单映射$allowSort [ id s.id, price s.purchase_price, expire s.expire_date, ]; $sortField $allowSort[Request::param(sort, id)] ?? s.id; $direction Request::param(asc, asc) desc ? desc : asc; $query-order($sortField . . $direction);排序字段永远从白名单取而不是直接把参数拼进 order。另一个和漏洞相关的习惯是环境配置开发期 APP_DEBUG 开着能看到完整 SQL 和文件路径生产环境必须关掉错误页也不该显示框架版本号。老 ThinkPHP 项目被批量扫描很大一部分就是靠错误页信息判断版本后打已知漏洞点新项目至少做到 debug 关闭、后台口令有强度校验、不带 token 的写操作一律拒绝这三个底线守住系统就具备基本抵抗力。5. 基于 ThinkPHP 的交付前检查环境切换、目录裁剪与流水对账5.1 环境切换与缓存清理打包之前先做三件事config/app.php 里把 APP_DEBUG 改成 false防止错误详情和 SQL 语句泄漏删除 runtime 目录下的缓存和编译文件否则 zip 换到别的机器会引用旧路径写一份 .env.example 替代真实 .env数据库口令脱敏后再进压缩包。PHP 版本号在 README 里写明「PHP 8.0MySQL 5.7」评审机器复现不了时先查这两行而不是让评审装扩展。5.2 zip 包内容检查清单检查项处理方式理由.env 文件替换为 .env.example交付包里不含任何真实口令runtime 目录清空后保留 .gitignore日志和编译缓存会污染演示现场vendor 目录完整保留评审环境大多没有 Composersql 脚本放 database 目录评审第一步通常是建库导数据README.md写明版本、伪静态、后台账号减少沟通成本演示更顺伪静态和后台初始账号这两项最容易被忽略ThinkPHP 项目在 Nginx/Apache 下不配伪静态访问路径要带 index.php评审机器配置不一README 里给两段现成配置改动就能跑。5.3 对账脚本库存表和流水表必须自洽交付前最后一个动作是对账。因为所有流水统一了符号入库正、出库负理论库存就等于批次流水求和一条 SQL 就能找出所有对不上的批次SELECT s.id, s.drug_id, s.batch_no, s.qty AS real_qty, IFNULL(SUM(sl.qty), 0) AS log_qty FROM ph_stock s LEFT JOIN ph_stock_log sl ON sl.drug_id s.drug_id AND sl.batch_no s.batch_no GROUP BY s.id, s.drug_id, s.batch_no, s.qty HAVING real_qty log_qty;real_qty 是当前库存log_qty 是这个批次所有流水之和。HAVING 返回空集说明库存与流水一致返回任何一行就说明存在某条代码路径改了库存表但没写流水。把它包成 ThinkPHP 命令行任务演示前一键自检use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class CheckStock extends Command { protected function configure() { $this-setName(check:stock); $this-setDescription(库存与流水对账); } protected function execute(Input $input, Output $output) { $rows Db::query( SELECT s.id, s.drug_id, s.batch_no, s.qty AS real_qty, IFNULL(SUM(sl.qty), 0) AS log_qty FROM ph_stock s LEFT JOIN ph_stock_log sl ON sl.drug_id s.drug_id AND sl.batch_no s.batch_no GROUP BY s.id, s.drug_id, s.batch_no, s.qty HAVING real_qty log_qty ); if (empty($rows)) { $output-writeln(info对账通过/info); return 0; } Db::name(stock_tick_error)-insertAll($rows); $output-writeln(error发现 . count($rows) . 条不一致/error); return 1; } }在 config/console.php 的 commands 数组里注册该类后命令行执行php think check:stock即可。比对结果写入 stock_tick_error 表而不是日志文件第二天能直接查明细也避免日志滚动把历史不一致记录冲掉。上线后可以把这个命令挂 cron 每天凌晨跑一次报警只发不一致的行——库存账平不平一行 SQL 就能盯住。本文还有配套的精品资源点击获取
返回列表