SpringBoot药店管理系统实战:批次库存与进销存设计
手里的毕设课题是SpringBoot药店管理系统这类题先别急着敲代码。我见过太多人拿到题目就往Controller里堆接口结果业务逻辑和页面全缠在一起最后连自己都理不清。这篇就用一个完整的药店管理系统作为例子从需求拆解、技术选型、数据库设计到核心业务实现、前后端联调每一步都讲清楚为什么这么做以及我实际开发中踩过的坑。不管你是准备答辩的学生还是想快速上手全栈项目拿去做二次开发的开发者这篇文章都值得你花二十分钟看完。1. 先搞清楚药店管理系统真正要管什么业务边界与需求拆解很多人一看到管理系统三个字脑子里立刻蹦出增删改查。没错底层确实是增删改查但药店管理系统和其他进销存系统有本质区别——它管的是药品药品有批次、有有效期、有严格的进货销存关联规则这些约束直接决定了系统的数据模型和业务流程。1.1 角色权限一个药品管理系统最少要有三类人药店的操作场景通常分前台和后台。前台收银员负责接待顾客、开销售单、查库存后台管理员负责药品信息维护、进货入库、供应商管理如果再细分还可能有专门的仓库管理员和店长看统计报表。所以系统的用户表不能只存一个admin了事。我建议直接设计用户表 角色表 用户角色关联表的三表结构。原因很简单SpringBoot加上Spring Security或拦截器后续扩展权限会非常方便。比如药品新增这个按钮管理员能点、店长能看、收银员根本看不到这种细粒度控制靠一张表根本做不了。1.2 药品主数据不要只存一个药品名称药品商品名和通用名往往不一样比如泰诺和对乙酰氨基酚片是同一个东西。药店管理系统里药品表至少要包含药品基本信息名称、通用名、规格、剂型、生产厂家、批准文号采购信息进货价、零售价、会员价、供应商ID库存信息当前库存数量、预警阈值、存放货架号状态信息是否下架、是否为处方药、是否为医保药品特别注意批准文号这个字段。如果系统要严格按照医药流通规范进行管理批准文号应该是唯一索引级别的重要字段。它就像药品的身份证同一厂家同一规格的同一种药批准文号是唯一的。1.3 进货、销售、库存三者的联动系统最核心的数据流药店业务的本质是一条链供应商 → 进货入库 → 库存增加 → 销售出库 → 库存减少 → 库存不足再进货。系统中必须有三张核心业务表承接这个过程进货单 / 进货单明细记录什么时候从哪个供应商进了什么药、进价多少、数量多少、批次号是什么销售单 / 销售单明细记录什么时候卖出了什么药、售价多少、数量多少、收银员是谁批次库存表记录每个批次的药品还剩多少我特别要强调批次库存表。很多初学者把库存数量直接怼在药品表上进货加、销售减完事。这样做系统确实能跑但遇到一个现实问题就崩了——药品过期。不同批次的药品有效期不同零售的时候应该遵从近效期先出原则也就是先卖快过期的批次。如果你没有批次库存表这个需求根本没法实现最后只能糊弄答辩说系统暂不支持批次管理。实际做的时候批次表是必须有的下文会详细讲实现。2. 技术栈选型为什么SpringBoot Vue是毕设的最优解说句实在话药店管理系统在业务上并不复杂技术上也不需要上微服务、Redis缓存、消息队列那一套。毕业设计和技术选型的核心原则是在企业级主流技术栈上做减法既体现你掌握了主流开发方式又不把项目搞得难以完成。2.1 后端SpringBoot 2.x MyBatis-Plus是稳妥组合SpringBoot 2.7.x是我个人推荐的版本。SpringBoot 3.x需要JDK17起步如果你的电脑装的是JDK8直接选3.x会遇到一堆环境问题纯属给自己找不痛快。SpringBoot 2.7配合JDK8稳定、资料多、遇到问题随便一搜就有解决方案。持久层框架我强烈建议用MyBatis-Plus而不是纯MyBatis。原因有三单表CRUD不用写SQLBaseMapper自带insert/update/delete/selectById开发效率翻倍自带分页插件写一个配置类就能用PageHelper式分页查询LambdaQueryWrapper写条件查询非常优雅比如查询库存低于100的药品一行代码搞定如果你是老手当然可以手写各种XML映射文件做个复杂联表查询展示水平。但对毕设来说项目能按期完成、代码清晰可维护比炫技更有价值。2.2 前端选择Vue2 Element UI仍然是主流兼容性最好的组合Vue3确实已经普及好几年了但Vue2 Element UI的组合在大量教学资源和现成模板的加持下仍然是毕设项目的高成功率选择。如果你对Vue3的Composition API足够熟悉用Vue3 Element Plus也可以但如果你只是想快速把页面跑起来Vue2的Options API确实更适合入门。前端模块我建议按这个结构组织登录页记住密码 登录角色区分跳转首页工作台库存预警提醒、今日销售额、待处理进货单药品管理药品列表、新增药品、编辑、下架、模糊搜索进货管理进货单创建、入库、进货历史销售管理收银台开单、销售历史、退货功能库存管理批次库存查询、入库批次详情供应商管理供应商列表、新增编辑、关联药品系统管理用户管理、角色权限、修改密码页面数量不算少但每个页面基本都是表格 弹窗表单 搜索栏的结构Element UI的Table、Dialog、Form组件可以直接复用工作量没有想象中那么大。2.3 环境与数据库MySQL 8.0 可视化工具数据库没什么好纠结的MySQL 8.0是主流版本Navicat或者DataGrip选一个顺手的。要注意MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver和5.x的com.mysql.jdbc.Driver不一样配置连接串的时候记得带上serverTimezoneAsia/Shanghai否则时间字段会差8个小时。3. 数据库设计的核心细节表结构、字段类型与关联关系数据库设计是系统的地基地基歪了后面写多少代码都难受。这一节我直接给出设计思路和关键字段你可以根据自己的需求扩充。3.1 核心表清单与字段设计用户相关-- 系统用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt加密, real_name VARCHAR(50) COMMENT 真实姓名, phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME );角色和权限表按简单方案做一张角色表存储角色编码和名称用户表通过role_id直接关联即可。毕设级别的系统没必要做完整的RBAC五表模型用户角色一对一关联已经能说明你理解权限控制。药品与批次相关-- 药品信息表 CREATE TABLE drug ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(50) UNIQUE COMMENT 药品编码自定义生成规则, drug_name VARCHAR(100) NOT NULL COMMENT 药品名称, generic_name VARCHAR(100) COMMENT 通用名, specification VARCHAR(50) COMMENT 规格如0.25g*24片, dosage_form VARCHAR(20) COMMENT 剂型, manufacturer VARCHAR(100) COMMENT 生产厂家, approval_number VARCHAR(50) COMMENT 批准文号, unit VARCHAR(10) COMMENT 单位盒/瓶/袋, purchase_price DECIMAL(10,2) COMMENT 进货价, sale_price DECIMAL(10,2) COMMENT 零售价, member_price DECIMAL(10,2) COMMENT 会员价, stock_threshold INT DEFAULT 50 COMMENT 库存预警阈值, is_prescription TINYINT DEFAULT 0 COMMENT 是否处方药, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME ); -- 药品批次库存表 CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(50) NOT NULL COMMENT 批次号, production_date DATE COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 有效期至, stock_quantity INT NOT NULL DEFAULT 0 COMMENT 当前批次剩余数量, supplier_id BIGINT, create_time DATETIME );业务单据表-- 进货单主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) UNIQUE COMMENT 进货单号如PO202501151001, supplier_id BIGINT NOT NULL, total_amount DECIMAL(10,2) COMMENT 进货总额, status TINYINT COMMENT 0待入库 1已入库 2已取消, operator_id BIGINT COMMENT 操作人, create_time DATETIME ); -- 进货单明细表 CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, batch_no VARCHAR(50), quantity INT, purchase_price DECIMAL(10,2), expire_date DATE ); -- 销售单主表 CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(30) UNIQUE COMMENT 销售单号如SO202501151001, total_amount DECIMAL(10,2), payment_type TINYINT COMMENT 1现金 2微信 3支付宝 4医保, cashier_id BIGINT COMMENT 收银员ID, member_id BIGINT, create_time DATETIME ); -- 销售单明细表 CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, batch_id BIGINT COMMENT 销售时扣减的批次ID, quantity INT, sale_price DECIMAL(10,2) );3.2 大字段类型选择的两个重要提示金额字段一律用DECIMAL(10,2)千万别用FLOAT或DOUBLE。浮点数在二进制存储下无法精确表示0.1这种值累计计算会出现0.30000000000000004这类恶心结果。药店系统天天和钱打交道精确性必须是第一位。时间字段用DATE表示生产日期和有效期用DATETIME记录订单创建时间。不要偷懒全部用VARCHAR存时间否则后续做查询近效期药品时要写字符串比较的SQL逻辑别扭且容易出错。3.3 表关联不要滥用外键约束我建议在逻辑上保留关联关系但不一定在数据库层面建立物理外键。理由很实际销售单明细被删除时MySQL外键约束会阻止删除导致删除一条销售记录这种常规操作报错批量导入测试数据时外键约束会使导入顺序必须严格遵循父子表先后关系你开发调试时手动改库外键会给你使绊子正确做法是在表设计文档中说明逻辑外键关系代码中通过Service层保证业务完整性数据库层面只保留必要的唯一索引和普通索引。4. 核心业务代码实现进货入库、销售出库、库存预警三大硬骨头系统好不好用不看页面多漂亮看核心业务函数能不能扛住真实场景。这一节我挑最有含量的三段代码逻辑展开讲。4.1 进货入库事务保证主单、明细、批次库存同时更新进货入库是一个跨多表操作必须加Transactional注解保证原子性。伪代码思路如下Transactional public void purchaseStock(PurchaseOrderDTO dto) { // 1. 生成进货单号 String orderNo PO DateUtil.format(new Date(), yyyyMMddHHmmss); // 2. 保存进货单主表 PurchaseOrder order new PurchaseOrder(); order.setOrderNo(orderNo); order.setSupplierId(dto.getSupplierId()); order.setStatus(0); // 待入库 purchaseOrderMapper.insert(order); BigDecimal totalAmount BigDecimal.ZERO; for (PurchaseItemDTO item : dto.getItems()) { // 3. 保存进货明细 PurchaseOrderItem orderItem new PurchaseOrderItem(); orderItem.setOrderId(order.getId()); orderItem.setDrugId(item.getDrugId()); // 4. 判断批次信息如果传了批次号就落到批次表否则自动创建新批次 DrugBatch batch new DrugBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(item.getBatchNo()); batch.setExpireDate(item.getExpireDate()); batch.setStockQuantity(item.getQuantity()); batch.setSupplierId(dto.getSupplierId()); drugBatchMapper.insert(batch); orderItem.setBatchNo(item.getBatchNo()); orderItem.setQuantity(item.getQuantity()); orderItem.setPurchasePrice(item.getPurchasePrice()); purchaseOrderItemMapper.insert(orderItem); // 5. 累加总额 totalAmount totalAmount.add( item.getPurchasePrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 6. 更新主表总额和状态 order.setTotalAmount(totalAmount); order.setStatus(1); // 已入库 purchaseOrderMapper.updateById(order); }注意第4步药品批次入库时机有讲究。有的系统是先建进货单再单独做审核入库这样设计更符合门店管理的审批流。如果你的毕设也要体现这个流程可以把状态改为0待入库 → 1已入库在状态变为1时再去插入批次表。这样代码结构更清晰答辩时还能多讲一个业务状态机的概念。4.2 销售出库先锁库存、再扣批次、后写流水销售出库比进货更繁琐因为它要解决**从哪个批次扣**的问题。正确逻辑是遍历销售购物车明细对每个药品查出所有未过期批次按有效期升序排序从最早过期的批次开始扣减扣完一个批次再扣下一个批次扣减同时写销售明细记录本次销售来自哪个批次计算销售总额生成销售单Transactional public SaleOrder createSaleOrder(SaleOrderDTO dto) { SaleOrder order new SaleOrder(); order.setOrderNo(SO DateUtil.format(new Date(), yyyyMMddHHmmss)); order.setCashierId(dto.getCashierId()); order.setPaymentType(dto.getPaymentType()); BigDecimal totalAmount BigDecimal.ZERO; ListSaleOrderItem saleItems new ArrayList(); for (SaleItemDTO item : dto.getItems()) { // 1. 查询该药品的所有有效批次过期时间大于今天剩余数量大于0按过期时间升序 ListDrugBatch batches drugBatchMapper.selectValidBatches( item.getDrugId(), new Date()); int remainingQty item.getQuantity(); for (DrugBatch batch : batches) { if (remainingQty 0) { break; } int deductQty Math.min(remainingQty, batch.getStockQuantity()); // 2. 扣减当前批次数量用乐观锁更新防止并发超卖 int rows drugBatchMapper.deductStock( batch.getId(), deductQty, batch.getStockQuantity()); if (rows 0) { throw new RuntimeException(批次库存不足或已被他人修改请刷新后重试); } // 3. 记录销售明细 SaleOrderItem saleItem new SaleOrderItem(); saleItem.setOrderId(order.getId()); saleItem.setDrugId(item.getDrugId()); saleItem.setBatchId(batch.getId()); saleItem.setQuantity(deductQty); saleItem.setSalePrice(item.getSalePrice()); saleItems.add(saleItem); remainingQty - deductQty; totalAmount totalAmount.add(item.getSalePrice() .multiply(BigDecimal.valueOf(deductQty))); } if (remainingQty 0) { throw new RuntimeException(药品【 item.getDrugName() 】库存不足); } } order.setTotalAmount(totalAmount); saleOrderMapper.insert(order); for (SaleOrderItem saleItem : saleItems) { saleItem.setOrderId(order.getId()); saleOrderItemMapper.insert(saleItem); } return order; }这段代码里有两个非常值得你写进论文的设计点并发控制。deductStock方法用了乐观锁SQL大致是UPDATE drug_batch SET stock_quantity stock_quantity - #{qty} WHERE id #{id} AND stock_quantity #{qty}。这样即使两个收银员同时卖最后一个批次也不会出现库存扣成负数的问题。不用悲观锁的原因是药店系统并发量不高乐观锁的实现更简单不用在事务里SELECT FOR UPDATE。近效期先出。通过ORDER BY expire_date ASC选择批次保证了系统自动遵循医药零售的近效期先出原则。就这一个细节就能在答辩时讲出两分钟的干货。4.3 库存预警定时任务只统计变化不用实时计算库存预警有两种实现思路实时计算每次查询药品列表时LEFT JOIN批次表聚合出总库存与阈值比较。实现简单但如果数据量大列表页多条聚合查询会拖慢速度。定时任务 预警表写一个Scheduled方法每天凌晨统计库存低于阈值的药品存入预警表前端查询预警表展示。适合需要已读/未读状态管理的场景。我建议结合使用。列表页用实时计算方式显示每行药品的库存状态首页工作台用定时任务生成每日预警简报。定时任务用SpringBoot自带的Scheduled就够了不需要引入Quartz这种重量级框架。Component public class StockWarnTask { Scheduled(cron 0 0 8 * * ?) // 每天早上8点执行 public void generateDailyWarn() { ListDrug lowStockDrugs drugMapper.selectLowStockDrugs(); // 存入预警记录表或推送到消息通知表 } }别忘了在启动类加EnableScheduling开启定时任务这个细节漏了任务永远不会执行排查半天才发现。4.4 前后端交互统一返回结构 登录状态校验前端所有请求后端统一返回一个JSON结构{ code: 200, message: 操作成功, data: { } }这个结构用一个泛型类实现配合全局异常处理器前端axios拦截器里就能统一判断code、弹出错误信息不用每个请求都写一遍错误处理。登录状态用Token方式前端登录成功后把Token存到localStorage每次请求带在Header里后端用拦截器统一校验。毕设级别用JWT或简单的UUID Token都行我推荐JWT因为它的无状态特点可以顺带在论文里写一段基于Token的无状态认证机制比老旧的Session机制更能跟上企业主流技术。5. 开发过程中会真实踩到的坑一次排查完整的销售出库库存变负数经历我不可能把系统里所有代码贴出来但调试过程值得单独写一段。做这个系统的朋友百分之八十都会遇到库存相关的问题我讲一个最典型的排查过程你遇到类似问题可以直接套用排查思路。5.1 问题现象模拟项目X测试阶段测试人员在销售端开了一张销售单买了两盒阿莫西林胶囊。订单创建成功后打开药品列表发现阿莫西林库存变成了-2。复现这个异常第一反应是减库存的代码写错了但检查Service层发现逻辑没有明显bug于是怀疑到并发上——会不会是同一个账号同时开了两个窗口提交订单5.2 排查链路先在日志中搜索那条销售单的完整操作记录发现创建时间精确到秒。因为生产库和测试库的日志会分开所以我先在测试库复现开了两个浏览器窗口模拟同一账号同时下单。复现成功后我远程到方法栈观察实际执行顺序发现两个线程几乎同时执行到了deductStock但两个事务提交后库存变成-2。问题根源在SQL写得太随意-- 错误写法先查后更查询和更新之间存在时间差 UPDATE drug_batch SET stock_quantity stock_quantity - 2 WHERE id 123; -- 正确写法条件判断加在SQL里一条语句原子完成 UPDATE drug_batch SET stock_quantity stock_quantity - 2 WHERE id 123 AND stock_quantity 2;先查再减的写法在并发下常见问题两个请求都查到库存是2都认为我这次扣减没问题然后先后执行了减2的操作最终库存就是-2。把条件stock_quantity 2写进SQL后第二次请求的UPDATE会返回0行受影响代码里判断影响行数就能阻止超卖。5.3 修复后还要做的验证修复后可以做一个简单压测用测试脚本同时发起10个销售请求每个请求卖两盒同一种药品药品初始库存设置为20。跑完断言总卖出数量不超过20、数据库库存不为负数。这一步不是可选的而是必须做的否则你根本不确定并发问题是否真正解决。5.4 其他高频问题速查表问题根因解决方案时间字段查询差8小时MySQL连接串缺少serverTimezone配置连接串加上serverTimezoneAsia/Shanghai前端传JSON日期格式解析失败Jackson默认格式不匹配配置spring.jackson.date-formatyyyy-MM-dd HH:mm:ss修改密码后旧Token仍然有效拦截器未校验密码变更时间Token中携带密码版本号变更即失效删除供应商提示外键冲突逻辑外键没有做是否被引用查询删除前先查进货单表是否存在该供应商记录分页插件不生效Page对象里total为0MyBatis-Plus分页插件未注册为Bean配置类中Bean注册PaginationInnerInterceptor6. 交付物怎么整理说明文档、部署步骤和个人心得毕设除了代码能跑文档同样重要。药店管理系统这类项目最终交付物通常包括六样东西我按优先级给你列出来。6.1 完整代码与SQL脚本代码要分目录管理backend放SpringBoot工程frontend放Vue工程doc放数据库脚本和说明文档。数据库脚本要包含建库建表语句和初始测试数据。很多同学图省事数据库脚本里只建表没有数据导致答辩演示时登录都登不上去。至少给三个用户、十个药品、两个供应商、几条销售记录的测试数据。6.2 说明文档的写作思路说明文档最忌讳写成安装步骤三个字敷衍了事。一份合格说明文档至少包含系统概述与功能模块清单数据库设计说明书E-R图 表结构说明 核心表关系描述核心业务流程图文说明进货流程、销售流程、库存预警流程系统部署文档JDK配置、MySQL导入、后端启动、前端启动测试用例表功能点、操作步骤、预期结果、是否通过写部署文档的时候有个细节要把后端启动命令、前端启动命令都真实执行一遍把实际窗口上显示的端口号和启动成功日志截图放进文档。不要代码写完了再凭记忆补文档那必然漏步骤。6.3 答辩演示的准备要点答辩现场最容易翻车的地方不是功能bug而是演示数据不连贯。比如你想演示库存不足预警结果系统里所有库存都是几千盒摸不着头脑。我的建议是提前准备一套完整的演示剧本。比如先在50盒阿莫西林中卖出去48盒再演示库存预警列表出现这条记录先进两批不同有效期的同一药品再演示销售时自动优先扣减近效期批次。每一步都有对应的数据和预期页面答辩才能游刃有余。6.4 我个人的几点体会做这个项目的过程中我最大的体会是最好的毕设不是功能越多越好而是每个功能都能说清楚为什么这么做。比如近效期先出这个业务规则写代码前我以为是药品卖得快就行后来请教了一位在连锁药房做过系统的朋友才明白药品不能简单当作普通商品管老百姓从药房买到过期药是严重的质量事故所以批次和有效期管理才是药品管理系统的灵魂。另外如果你打算把项目放进简历里建议把下面几个点写清楚系统中如何保证库存不超卖乐观锁、如何获得待入库状态机转换、如何实现近效期批次优先出库。这三个点虽然是业务细节但能证明你考虑问题不只停留在CRUD层面而是理解了真实业务场景里的痛点。做完整套系统之后我的建议是先跑通最小闭环再逐步加功能。很多同学喜欢一开始就把所有页面和功能全部铺开结果中途发现数据库设计有问题推倒重来。先实现药品管理 进货 销售 库存这条主线让数据完整流转起来再加权限、供应商、报表和统计。这样每天能看见进度也有安全感。希望这篇东西能帮你在做药店管理系统时少走点弯路把力气花在真正重要的地方。