基于ThinkPHP+Vue的中药仓库管理系统设计与实践

基于ThinkPHP+Vue的中药仓库管理系统设计与实践 做药店中药仓库管理系统这件事是我帮一个做医药流通的朋友处理库存管理需求时真正动起来的。当时他们还在用Excel记录几百种中药饮片的进销存效期、批次、养护记录全靠人工翻台账一到盘点就头大。我调研了一圈之后定了thinkphpvue这套技术栈来做整个系统最后交付的版本跑得很稳这里把设计与实现的过程完整复盘一遍给同样准备做仓库管理类系统的朋友一个参考。这套系统本质上解决的是中药仓库管理中的三个核心问题批次级库存追踪、效期预警、以及采购入库到出库领用的全流程记录。适合的业务场景是单体药店、中小型连锁药店以及中药饮片批发商技术上则是一个标准的thinkphp后端 vue前端前后端分离项目。我把整个设计和实现拆成了几个部分来讲先聊需求分析和业务建模再讲技术选型与项目架构然后重点拆解数据库设计和核心接口实现最后是前端落地、部署上线和踩坑记录。每一步都会给出关键代码和设计理由希望能帮你少走弯路。1. 项目启动前先理清中药仓库管理的真实痛点1.1 中药仓库和西药仓库管理为什么差别很大很多朋友一听仓库管理系统第一反应是把常见的小商品进销存拿过来改改就能用。但真正深入中药仓库的日常操作之后你会发现完全不是一回事。西药大多是标准化生产的成品药一个SKU对应一个批号有效期明确包装规格相对统一。中药饮片则完全不同同一味药材可能来自不同产地、不同供应商、不同采收季节质量差异很大必须按批次管理而且中药饮片对存储条件敏感容易受潮、虫蛀、走油必须有养护记录和效期管理再看销售和处方环节中药处方抓药经常一剂需要十几味药材每味几克十几克但入库时却是按公斤来的这就牵扯到单位换算。所以说中药仓库管理系统的核心不是简单的增删改查而是如何精细地追踪每个批次的药材从哪来、放在哪、什么时候到期、哪个批次先出库。用面向普通商品的库存逻辑去套中药业务一定会出问题。1.2 业务流程梳理与功能清单动手写代码之前一定要把业务流程捋清楚。我一般习惯先画一遍实际业务串联图再转成功能清单。这个项目里核心业务链路是这样的供应商供货采购员做采购单药材到货后验收验收合格后入库并生成批次库存记录日常销售或处方抓药时从批次库存中按先进先出原则出库扣减库存不足或效期临期时提醒采购和养护人员仓库管理员定期生成养护任务记录养护结果最后所有操作都有流水记录方便追溯。基于这个链路我把系统划分为六大功能模块基础数据管理药材字典、药材分类、供应商档案、仓库货位采购管理采购单创建、审批、到货验收、采购退货库存管理批次库存查询、效期预警、库存盘点、报损出库、库存流水出库管理处方/销售出库、领用出库、出库单管理养护管理养护计划、养护任务执行与记录系统管理用户、角色、权限、操作日志、数据字典功能范围不是拍脑袋定的而是把仓库管理员、采购员、药师、店长这几个角色的日常工作逐一列出来再合并去重得到的。这里要记住一句话需求阶段多花一天开发阶段少返工一周。2. 技术选型为什么是ThinkPHP Vue2.1 后端用ThinkPHP的现实考量技术在真实项目里从来不是越新越好、越复杂越好关键是团队熟不熟、维护成本高不高、和现有业务系统好不好对接。这套系统后端我选了ThinkPHP 6.0.12 LTS版本原因有几个第一中小型团队甚至个人开发者对ThinkPHP非常熟悉文档齐全生态成熟出了问题能快速找到解决方案。LTS版本意味着长期维护安全性有保障。第二ThinkPHP内置了ORM、路由、中间件、验证器、命令行等常用能力对于进销存这种典型的管理信息系统来说开发效率很高不需要额外引入重量级框架。第三性价比。药店管理系统本身的并发量并不高一台便宜的单机服务器跑PHP-FPM完全够用不需要为了追求高大上而上微服务、消息队列这种东西那是给自己挖坑。后端整体的分层我按ThinkPHP官方推荐的模式来组织Controller负责接收请求和返回响应Service层负责业务逻辑Model层负责数据操作Validate负责参数校验。小项目里很多人图省事把业务逻辑全写到Controller里但一旦需求变多、逻辑变复杂Controller会膨胀得非常快后期维护会很痛苦。2.2 前端用Vue的取舍前端选了Vue更具体地说是Vue 3 Element Plus Vite Pinia Vue Router Axios这套组合。为什么用Vue而不是JQuery服务端渲染模板核心原因是交互体验。仓库管理虽然以表格和表单为主但还是有不少需要即时反馈的地方比如库存检索、出库单动态添加明细、效期预警的图表展示这些用SPA来做会很顺手而且维护起来比在HTML里拼字符串干净得多。Vue 3的Composition API对于复杂业务组件的代码复用非常友好。比如多个模块都要用药材选择器这个通用功能用组合式函数抽取出来之后到处都能复用不用再复制一大段options代码。如果你还在用Vue 2的Options API写大型项目我建议尽早切换写完这个系统你会发现两者的组织效率差距确实很大。UI组件库用了Element Plus。前面热词里提到的自动导入ElementPlus、elmessage未定义这些坑后面我会专门讲。选Element Plus理由很简单组件齐全、文档清楚、企业管理系统尤其适合表格、弹窗、表单、日期选择这些高频组件都有现成方案。2.3 前后端分离的联调约定前后端分离不只是技术上分开协作方式也要有明确约定。这个项目从一开始就定了几条接口规范所有接口统一返回JSON结构{ code: 0, msg: success, data: {...} }成功时code为0失败时code为业务错误码认证用JWT请求头带Authorization: Bearer分页参数统一是page和limit分页返回结构统一是{ total, list }所有时间字段统一用Y-m-d H:i:s格式涉及金额和数量的字段由后端统一用decimal类型传给前端前端不参与精度运算这些约定看着琐碎但能省掉无数联调时的扯皮。我见过太多前后端各搞一套返回格式最后联调阶段天天改代码的例子。提前定好规范大家按规矩做事开发效率会高很多。3. 数据库设计与核心业务实现3.1 表结构设计的核心思路数据库设计是整个系统的地基我前前后后改了三个版本才定稿。核心表大概有这些药材字典表 medicineid、名称、编码、拼音码、分类、规格、库存单位、采购单位、换算比例、养护条件、默认保质期、库存上下限供应商表 supplierid、名称、联系人、电话、地址、资质信息批次库存表 batch_stockid、药材ID、批次号、供应商ID、货位ID、数量、单位、生产日期、有效期、入库时间、状态库存流水表 stock_logid、药材ID、批次ID、变动类型入库/出库/报损/盘点调整、变动前数量、变动数量、变动后数量、关联业务单号、操作人、操作时间采购主表 采购明细表purchase_order 和 purchase_order_item出库主表 出库明细表stock_out 和 stock_out_item用户/角色/权限表user、role、permission 及关联表操作日志表 op_log设计时几个关键决策我展开说一下。中药必须做批次管理。为什么因为同一味药材不同批次的效期、质量、供应商都不同。如果只按药材汇总库存数会出现一个严重问题系统显示有库存但实际这批药材已经过期了。批次库存表把数量精确到每个批次出库时才能按先进先出(FEFO)原则选择最先到期的批次扣减。设计一个统一的库存流水表。所有库存变动都往这张表里写采购入库写一条入库流水销售出库写一条出库流水盘点调整也写一条。全链路可追溯审计的时候只要查这一张表就行不用去翻无数张业务表。这也是和普通进销存系统一个很重要的区别。药材字典里的单位和换算比例单独设计。举个例子有的药材入库按公斤记录但处方抓药按克出库。我加了一个单位和换算比例字段入库50公斤出库时换算成50000克所有业务操作前先统一到一个基准单位避免搞混。3.2 库存变动核心代码实现数据库设计好之后接下来是库存变动的核心逻辑。这部分是整个系统的灵魂出库扣减、入库增加每一行代码都要谨慎。以出库为例核心流程是校验库存按先进先出选择批次扣减批次库存写入流水。整个流程必须在一个数据库事务里完成。思路大致是这样的public function outStock(array $items, string $bizType, string $bizNo) { Db::startTrans(); try { foreach ($items as $item) { $medicine Medicine::where(id, $item[medicine_id])-lock(true)-find(); if (!$medicine) { throw new \Exception(药材ID:{$item[medicine_id]}不存在); } // 这里先汇总校验防止批次分散导致误判 $totalStock BatchStock::where(medicine_id, $item[medicine_id]) -where(status, 1) -sum(quantity); if ($totalStock $item[quantity]) { throw new \Exception(药材:{$medicine[name]}库存不足); } // 先进先出扣减批次库存 $remainingQty $item[quantity]; $batches BatchStock::where(medicine_id, $item[medicine_id]) -where(status, 1) -where(quantity, , 0) -orderBy(expiry_date, asc) -orderBy(created_at, asc) -lock(true) -select(); foreach ($batches as $batch) { if ($remainingQty 0) break; $deductQty min($batch-quantity, $remainingQty); $batch-quantity - $deductQty; $batch-save(); StockLog::create([ medicine_id $item[medicine_id], batch_id $batch-id, change_type $bizType, before_qty $batch-quantity $deductQty, change_qty -$deductQty, after_qty $batch-quantity, biz_no $bizNo, operator_id $this-uid, remark $item[remark] ?? , ]); $remainingQty - $deductQty; } } Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } }这里的要点有几个首先每读取一次库存就加锁避免并发下超卖行锁比表锁代价小很多其次查询时按 effective_date 排序先到期的批次先扣减符合药品先进先出的原则再次每次扣减都记录变动前后的数量以后出任何问题都能追溯是谁在什么时候动了库存。入库逻辑类似但要注意采购单可能分批到货所以批次号生成规则要保证唯一。我采用日期随机数例如20250615001每天从001开始递增简单明了。入库时还要同步更新药材字典的最近入库时间、最近供应商等信息方便后续采购决策。3.3 效期预警和养护任务实现效期预警是一个必须有但不能做得太复杂的模块我采用了查询时动态计算的方式不额外存冗余字段。每味药材的有效期可能不同有的中药饮片保质期36个月有的只有12个月。我在药材字典里配置了默认保质期。预警规则就两条临期90天提醒采购和仓库不再补货临期30天强制要求报损或特别审批出库。具体实现是写一个查询接口按每个批次的有效期减去当前天数打上正常/临期/过期的标记。因为批次量不大每次全量计算一遍也没压力。同时利用ThinkPHP的自定义命令行任务每天凌晨扫描一次临期批次生成待办通知写入消息表。这里有一个重要的细节如果药店的经营规范有GSP要求临期和过期批次必须人工复核确认之后才能从系统里销账不能自动删除所以我的design是保留历史状态只在状态字段里做流转。养护任务的逻辑则是把哪些药材需要定期检查配置到药材字典里然后系统按设置的时间间隔自动生成任务。比如易受潮的药材养护周期设为15天每到周期就生成一条待养护记录仓库管理员执行完养护后填写结果系统记录日志。这个功能实现起来不复杂但对实际管理帮助非常明显。4. 前端Vue落地与联调细节4.1 前端项目结构与路由权限设计前端项目用的Vite初始化目录结构大致这样的src/ api/ # 接口请求封装 assets/ # 静态资源 components/ # 通用组件 composables/ # 组合式函数 router/ # 路由配置 store/ # Pinia状态 views/ # 页面组件 utils/ # 工具函数路由权限这块我用了最通用的方案路由守卫动态权限控制。用户登录后拿到角色权限列表前端根据权限动态生成菜单和路由。Vue Router的 beforeEach 守卫里做三件事判断token是否存在、判断是否已拉取用户信息、判断当前路由是否在权限列表里。没有权限就跳到403页面没登录就跳到登录页并带上回跳地址。router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token) { if (to.path /login) return next() return next(/login?redirect${encodeURIComponent(to.fullPath)}) } const userStore useUserStore() if (!userStore.userInfo) { try { await userStore.getUserInfo() const hasPermission userStore.hasPermission(to.meta.permission) return hasPermission ? next() : next(/403) } catch (e) { userStore.logout() return next(/login) } } return next() })这里要注意路由权限如果全靠前端做是不安全的黑客可以通过改前端代码绕过。所以后端接口仍然要校验权限前端控制只是优化体验真正的安全防线必须放在后端。4.2 表单校验、表格展示与接口封装管理系统的书写量基本都在先做表格再点编辑这种模式里。我基于Element Plus封装了一个PageTable组件把搜索条件、数据表格、分页器给包在一起页面里只要传配置就能渲染。好处是写新页面很快坏处是自由度下降。封装了一定的通用能力之后开发效率提升非常明显。表单部分Element Plus有 Form 校验能力。但要注意Rules的校验触发时机要配置对。我是这样处理的必填字段在 blur 和 change 时都触发数字字段统一转成数字再校验范围日期字段要注意 format 问题。Axios的封装里响应拦截器统一处理 code 不为 0 的错误提示HTTP 401 时自动跳登录页并清除本地状态。还有一点请求拦截器里要加上loading配置避免用户在操作时重复提交。像出库单这种重要的操作按钮在提交后要立即做 loading 置灰防止同一张单子被提交两次。接口这块再补一句thinkphp监听SQL的常见位置问题。经常有人问我在哪里能看到系统执行了什么SQLThinkPHP中一般建议注册Db::listen回调放在 service-provider 或者公共函数里统一处理。比如在 app/provider.php 里绑定一个事件监听输出到日志文件方便排查慢查询和异常SQL。// app/provider.php 或者中间件中 Db::listen(function($sql, $time, $explain) { // 写入日志文件线上环境可以只在debug模式开启 Log::channel(sql)-info([$time ms] . $sql); });4.3 联调阶段翻车实录前后端联调时最容易出问题的几个点这里列一下全部是我实际踩过的第一是跨域。ThinkPHP后端默认不允许跨域Vue开发服务器在 localhost:5173后端在 localhost:8088直接请求肯定报错。我的方案是在后端写一个全局中间件配置允许的跨域来源、请求方法、请求头并在处理OPTIONS预检请求时直接返回200。生产环境如果前端和后端部署在同一域名下就不用这么费劲了nginx反向代理就能解决。第二是时间格式化。PHP返回的时间字段是2025-06-15 10:30:00前端如果直接用 new Date() 去格式化有的浏览器会报Invalid Date。因为iOS对带空格的日期格式解析有兼容问题。我在Axios的响应拦截器里统一做一层数据清理遇到日期字符串就替换成T分隔再处理从源头规避各种兼容问题。第三是Element Plus组件的自动导入问题。用unplugin-auto-import和unplugin-vue-components可以自动按需引入组件和API。但要注意自动导入只负责模板里的组件像ElMessage、ElMessageBox这种直接通过JS调用的API如果没在配置文件里显式引入对应样式会出现为什么elmessage还是提示未定义或者样式丢失的问题。需要在vite配置的ElementPlusResolver里设置importStyle: sass并引入对应样式变量或者干脆在main.js里完整引入Element Plus的样式图个省心。我在项目里最终选择了手动按需引入主要组件ElMessage直接全量引入这样一个万金油方案最稳定。很多一键生成的工具配置文档不会主动告诉你这些坑碰到了只能靠经验。5. 核心模块实操从采购到出库的完整链路5.1 采购入库的完整流程实现采购入库是库存数据的源头如果这块设计得不严谨后面全盘崩。我的实现流程是这样的第一步采购员创建采购单填写供应商、采购日期、期望到货日期然后逐行添加采购明细每行选择药材、填写采购数量、采购单价。提交后单据状态是待审批。第二步店长或采购负责人审批。审批通过后状态变更为已审批此时系统锁定采购数量不允许随意修改。这个环节看似多余但在实际药店管理中非常有用能防止采购人员私自从供应商加购一些不合理的品种。第三步到货验收。仓库管理员在系统里点验收输入实际到货数量如果和采购数量不一致可以写差异原因。验收完成后系统自动生成入库单按实际到货数量生成批次库存记录。批次号设置为当天日期加序号比如20250615001批次记录生产日期、有效期、进货价、供应商。第四步系统自动写入库存流水并把本次入库关联到采购单和供应商上形成完整的溯源链条。这里有个细节中药饮片验收时需要填养护条件、生产批号等信息这些字段我直接从药材字典带出来减少了手工录入。同时入库操作发生时系统会检查是否已有同品种同批次的库存记录如果有不会自动合并而是要求人工确认能否合并。为什么要这样因为中药饮片的同批次定义非常严格必须是同一个生产批号如果仅凭外观相似就合并库存未来追溯会出大问题。5.2 出库单与处方抓药的联动实现出库模块里最有挑战性的是处方抓药场景。这个系统面向的主要场景是药店销售处方流程是这样的药师录入处方系统按处方中的药材明细自动生成出库单保存时自动检查每个药材的库存是否充足如果库存不足直接提示缺哪味药、差多少数量方便药师决定是否联系患者换药或调货。如果库存充足出库单提交后系统生成一个预占扣减记录。注意这里做了个锁定库存的设计避免两个药师同时操作明明库存还有却都被提示不足。具体实现我在 batch_stock 表上加了 locked_quantity 字段。出库单创建时先把数量加到锁定值上真正出库时扣掉库存和锁定值未完成出库但超过一定时间的单据自动释放锁定库存。这个细节在一开始设计时我漏掉了。第一版系统直接做减法结果遇到过这种情况两个药师同时给两个患者开同一味药系统显示库存5公斤足够但两个人同时点了提交一个成功一个失败患者体验很差。后来加上了锁定机制这个问题才彻底解决。另外出库明细里我还会记录处方号这样就能回溯这批药是哪位患者、哪个处方用掉的在发生药品质量问题时能第一时间完成药品召回和患者通知GSP审查时这就是救命功能。5.3 库存盘点与报损流程中药仓库的盘点和普通仓库最大的区别在于中药饮片会发生自然损耗——药材水分蒸发会减少重量储存过程中可能生虫而被丢弃。系统里的库存数量和实物数量往往有差异所以盘点模块不仅要能记录差异还要能说明差异原因。盘点方案我实现了两种盘点单模式和库存调整单模式。盘点单模式是定期做全盘或抽盘盘点单状态是草稿时盘点人员在里面录实盘数量录完后提交审核。审核通过后系统自动生成盘盈盘亏单并调整库存。库存调整单模式适用于日常小规模调整比如某味药受潮扔了一公斤直接在系统填一个报损单说明原因、关联药材和批次、填数量审核后自动出库扣减。这两种模式本质上都走同一条底层链路创建单据、审核、更新批次库存、写流水。我在Service层抽取了一个统一的库存变动方法盘点、报损、采购入库、处方出库都调同一个方法保证底层逻辑一致而不是每个模块各自写一套扣减代码。这也是为什么我刚才说Service层抽取业务逻辑很重要如果这些逻辑散落在Controller里根本没法做到统一管理。权限在这一块也必须严格。库存调整、报损单这类操作必须要高权限角色才能审核我用了ThinkPHP自带的权限中间件在路由配置里指定权限标识比如 stock:adjust:audit 只有管理员角色能访问。为了防止越权操作后端每个接口都要校验不能只在前端做权限控制。6. 常见问题与排查技巧实录6.1 库存负数、并发超卖和数据显示异常这个问题几乎每个做进销存系统的人都会遇到。我先把我踩过的坑按频率排个序库存变负数。排查下来最常见的原因是并发操作没加锁两个请求同时读到库存为1同时扣减成了负库存。解决办法是查询库存时加上 -lock(true)这样MySQL会锁定这行数据直到事务结束。另外一个原因是流程上有些出库单处于草稿状态但已经扣了库存用户反复提交导致的。这个通过锁定库存机制解决了。超卖。和库存负数同源也是并发问题。除了行锁之外我在出库前做了一次汇总库存预检两重校验基本能挡住99%的问题。剩下的1%比如极端高并发场景还可以通过数据库层的库存字段非负约束来兜底。分页数据错乱。最容易出现的情况是列表查询时没有固定排序条件数据库返回顺序不稳定翻页重复或漏数据。解决办法是排序字段一定要唯一一般用主键ID做最终排序条件保证分页稳定。再补充一个高频问题时间查询范围不对。PHP默认时区可能是UTC而中国要用东八区如果没设置date_default_timezone_set(PRC)查询今天的数据会凭空多出8小时。这个问题排查起来很隐蔽因为不是所有功能都会出错只有依赖日期统计的时候才暴露。6.2 ThinkPHP和Vue项目日常开发常见的坑我在开发这套系统时遇到的比较典型的坑列个表格方便你对照排查。现象可能原因解决办法前端显示正常但请求报401token过期或未携带Authorization头检查Axios拦截器确认请求头设置正确服务器时间是否偏移后端接收不到PUT/DELETE请求参数PHP默认对application/x-www-form-urlencoded以外的content-type解析受限前端统一用POSTJSON或后端配置json body解析表单提交后提示数据错误后端验证器校验失败但前端未显示具体错误信息后端统一返回出错字段名原因前端在表单中高亮显示上传图片返回404路由配置未开放public上传目录访问权限检查nginx或apache的静态资源alias配置部署后刷新页面404路由是history模式nginx未配置try_filesnginx配置 location / { try_files $uri $uri/ /index.html; }ThinkPHP连不上MySQL端口、账号、socket方式不对检查.env文件确认DB_HOST、DB_PORT配置分页查询很慢大数据量下未走索引给外键、日期、状态字段加索引用explain检查6.3 部署上线的主要步骤开发完成后部署上线也是一个高频问题。我的做法分三步走第一步后端部署。购买一台云服务器安装NginxPHP-FPMMySQL。把ThinkPHP项目代码放到站点目录配置好.env数据库连接信息确保storage目录有写权限。Nginx配置需要注意把站点根目录指向public文件夹并配置好PHP-FPM的fastcgi_pass参数。第二步前端部署。Vue项目执行npm run build生成dist静态文件目录。有两种部署方式第一种是直接把dist目录放到后端Nginx站点下用同一个域名访问这种方式最简单不会遇到跨域问题第二种是分开部署前端静态文件放CDN或独立服务器后端接口跨域调用这种方式需要额外配置跨域中间件。第三步日志与告警。上线之后把ThinkPHP的日志级别调整为生产模式关闭debug输出。同时配置sql日志和错误日志轮转防止磁盘爆满。我还会写一个简单的定时任务每天跑一遍效期预警扫描并推送消息到运营群让系统从被动记录变成主动提醒。稳定运行后这套系统在朋友药店的日常管理中使用率非常高批次效期预警尤其受欢迎仓库管理人员再也不用翻柜子挨个看有效期了。整个项目从需求梳理到最后上线前后大概用了一个月左右的工作量如果你已经有一定前后端基础这个周期是可复现的。最后分享一个让我改进最大的经验无论系统做得多花哨数据准确性永远是第一位的。做仓储管理系统宁可功能单一一点也不能让库存数据出现假账。代码可以重构功能可以迭代但如果用户对数据的信任崩塌了这个系统的价值基本就归零了。所以每一次库存变动都要记录全链路流水每一笔单据都要有明确的状态流转这些基础工作做到位了系统才真正可靠。