基于SpringBoot的药品供销系统:批次效期与进销存实战
1. 先说结论这个题目为什么比普通进销存难又为什么值得做每年毕业设计季后台私信和评论区里出现频率最高的一类题目就是基于SpringBoot的XX管理系统。而森伯药店供销系统这个名字乍一看和超市进销存图书管理系统没什么本质区别都是增删改查。但真正动手做的时候你会发现医药零售的进销存和普通商品进销存差了不止一个量级。普通进销存只需要管三件事进了多少货、卖了多少货、仓库还剩多少。但药品这个商品天然带着几道紧箍咒——批号管理每一批药的进货批次必须单独记录因为不同批次的效期、进货价可能完全不同效期管理过期药品不允许销售临近效期的药品要提前预警处方药合规销售处方药必须关联处方信息否则就是违规经营。这些规则叠加在进销存的通用逻辑上就让这个题目变复杂了。但反过来看这也正是这个题目在毕设里性价比极高的原因。选题有真实的行业背景背书功能覆盖面广采购、销售、库存、供应商、统计报表、预警提醒答辩时能讲的业务故事线也完整。你要是能把处方药分流、批次效期、先到期先出这些医药零售特有的逻辑讲明白指导老师基本不会为难你。这套系统的本质可以归纳成一句话围绕药品、供应商、门店/库存三个核心模型构建业务闭环。SpringBoot在其中的角色不是炫技而是把业务规则落到代码里——前端请求进来Controller接住Service层做业务校验和事务控制Mapper层和数据库打交道。想清楚这个链路你写的代码就不是堆功能而是让功能长在数据模型上。这篇文章我按需求拆解→技术选型→建表与核心流程→实测踩坑→答辩准备这条完整路径来讲既适合正准备开题的同学照着搭项目也适合已经写了一半、卡在某块逻辑上的人回来对照排查。2. 需求拆解把供销两个字翻译成一张能落地的功能清单供销看着简单其实是两个方向供是采购入库销是销售出库再加上药品特有的库存批次和效期管理整个系统的功能地图就清晰了。我带学生做这类项目时习惯先列功能清单再建表凡是在清单里出现的功能都会反推出一张或多张数据表。2.1 基础数据模块药品、供应商、客户、员工每张表都有行业特殊字段药品信息表是整个系统最核心的基础表。除了常规的编码、名称、规格、剂型、生产厂家、零售价、进货价之外医药零售场景下还必须带上几个特殊字段批准文号国药准字那串编号、生产批号、有效期至、存储条件常温、阴凉、冷藏、处方药标志。这些字段不是装饰直接决定了下游采购、销售、预警模块的写法。供应商表也不能只存公司名和电话。医药行业的供应商必须记录经营资质比如药品经营许可证号、GMP证书编号。药品是监管商品每一盒药都要求来源可追溯上游资质信息是追溯链的第一环。客户表区分零售客户和团购客户员工表则要关联登录账号和角色权限——将来查谁录的采购单谁审核的销售单都要从员工表往回追。2.2 采购入库流程每批药品都要带批号和效期入库这是医药和普通零售最大的分水岭我见过太多新手把批号和效期直接挂在药品表上当时觉得省事结果同一款药分两批进货之后效期管理彻底乱套——系统完全不知道哪一批还剩多少更谈不上先到期先出。这就是典型的表结构设计错误。正确的做法是拆两张表purchase_order采购单主表和purchase_order_item采购单明细表。主表记采购单号、供应商、总金额、状态待审核/已入库/已作废明细表逐条记录药品、批号、有效期至、采购数量、采购单价。入库时按明细逐条生成库存批次记录而不是简单叠加一个总库存数字。这里有个细节要特别强调同一种药品、不同批次的进货价可能不同。所以库存批次表里要单独存purchase_price不能一张嘴直接引用药品表的统一进货价。以后算销售毛利、做退货结算、分析滞销批次全靠这份批次级的价格数据。2.3 销售出库流程处方药和非处方药必须走不同的处理路径销售端的核心校验有三层。第一层是常规的库存是否充足第二层是效期是否临近或已过期——过期药品哪怕库存显示还有100盒也不允许出库第三层是处方药校验——销售处方药必须登记处方信息包括处方编号、开方医生、医院科室这些数据落到一张prescription表里。毕业设计里处方校验做到什么程度算合适说实话做到弹窗提醒录入处方编号已经能过基本关。但如果你想把这个做成答辩亮点还可以往上加一层程序性校验比如抗生素类药品单次限量、同一患者当日购药额度控制。这些规则写在Service层里不需要动数据库结构答辩时讲出来老师会觉得你是真理解业务而不是在背概念。2.4 库存与效期预警最容易做了个半截子功能的地方药品和普通商品最大的差异在于过期即报废不能降价促销只能报废处理。所以效期预警模块基本是我在评审毕设时必问的功能也是很多同学做得半截子的功能。完整的预警需求拆成三条线近效期查询列出N个月内到期的批次例如6个月内到期。过期药品自动锁定在销售出库时拦截过期批次不允许参与库存汇总。预警消息推送登录首页展示待处理预警数量管理员点进去看明细。三条线里第二条最容易被漏掉。很多项目做了近效期列表却忘了在销售Service里判断批次是否过期结果就是预警页面上显示某批次已过期但前台下单时这些过期库存照样能卖出去。我后面在踩坑部分会专门展开讲这个问题你如果已经写完代码记得回头检查一下销售逻辑里有没有效期过滤。3. 技术选型与架构落位SpringBoot到底解决了什么项目又该怎么分层3.1 为什么是SpringBoot它解决的是什么痛点如果你做过早期的SSHStrutsSpringHibernate或SSMSpringSpringMVCMyBatis应该记得那种配置地狱一堆XML文件换一个环境就可能跑不起来光是搭框架就得耗掉好几天。SpringBoot把约定优于配置落到框架层面默认配置能满足大部分场景需要调整时才写少量配置。对毕设来说这个价值很直接——你的时间应该花在业务逻辑上不是环境排错上。具体到药店供销系统SpringBoot有三个直接收益。第一Starter机制引入spring-boot-starter-web、mybatis-spring-boot-starter就够依赖版本由SpringBoot统一管理不用手工拼版本号。第二内嵌Tomcat不用单独安装Web容器打包成jar后直接java -jar运行部署成本几乎为零论文里写系统部署简单也更有底气。第三自动装配框架根据classpath下的依赖自动初始化对应Bean比如引入Redis依赖就自动配好RedisTemplate引入MyBatis就自动配好SqlSessionFactory。3.2 项目分层五层结构别让Entity直接裸奔到前端我推荐的分层是Entity实体→ Mapper/Repository数据访问→ Service业务逻辑→ Controller接口层→ DTO/VO数据传输层。很多毕设图省事直接把Entity对象返回给前端短期的确是少写几个类但代价是数据库表一旦调整接口响应结构跟着变前端所有绑定字段全部崩掉。DTO/VO这一层的价值就在于接口稳定。比如列表接口统一返回一个PageResultT包含total和records两个字段前端表格组件直接绑定所有列表页复用同一套逻辑。登录接口建议走JWT因为药店的收银员、店长、管理员角色权限不同多端场景下Session共享太麻烦。文件上传比如供应商资质扫描件走独立接口返回文件URL业务表里只存路径不存二进制内容。3.3 两个最容易被忽略的配置连接池和事务边界SpringBoot默认使用HikariCP连接池它本身很稳定但有个参数容易被忽略——maximum-pool-size。默认10个连接在毕设的低并发场景下够用但如果你的查询里有慢SQL连接很快就被占满然后整个系统卡死。我一般建议开发环境设到20左右同时在application.yml里配好连接超时时间别让它无限制等下去。事务管理是另一个高频翻车点。药店系统的销售出库需要同时扣减库存、生成销售记录、写入处方登记这三件事必须在一个事务里否则就会出现库存扣了但订单没生成这种数据不一致。SpringBoot里用Transactional标注在Service方法上但要特别注意同类内部方法自调用不走代理事务会失效。正确的做法是把事务边界放在Service类的方法上由外部调用触发而不是类内部this.method()自己调自己。3.4 日志与健康检查平时不起眼出问题时救命很多同学调试时遇到报错一脸懵因为日志级别是INFOSQL不打印堆栈信息也被截断排查全靠猜。推荐在项目里配一份logback-spring.xml至少做到三件事开发环境控制台输出SQL日志MyBatis的logging.level.com.example.mapperDEBUG、生产环境按天滚动写日志文件、报错时保留完整堆栈。这样出问题时能先看到SQL再定位业务逻辑而不是对着一个 NullPointerException 干瞪眼。另外可以加上spring-boot-starter-actuator暴露/actuator/health健康检查接口。部署到服务器之后先curl一下确认服务活了再去访问页面。很多毕设部署到云服务器后连服务没起来和页面404都分不清这个接口能帮你快速排除问题。虽然指导老师不一定检查这一步但论文里写一节系统可运维性设计观感会好很多。4. 建表与核心流程实现从数据模型到能演示的完整功能4.1 核心表结构设计一张表一张表讲清楚以MySQL为例设计时遵循第三范式同时针对查询场景做适度冗余。首先是药品表drug字段名类型说明idbigint主键drug_codevarchar(50)药品编码唯一drug_namevarchar(100)药品名称specificationvarchar(100)规格如0.25g*24片dosage_formvarchar(50)剂型片剂/胶囊/注射液manufacturervarchar(100)生产厂家approval_numbervarchar(100)批准文号prescription_flagtinyint是否处方药1是0否storage_conditionvarchar(50)存储条件常温/阴凉/冷藏sale_pricedecimal(10,2)零售价purchase_pricedecimal(10,2)进货价statustinyint1启用0停用批次/库存表stock_batch字段名类型说明idbigint主键drug_idbigint关联药品IDbatch_novarchar(50)批号expire_datedate有效期至quantityint当前库存数量purchase_pricedecimal(10,2)该批次进货价supplier_idbigint供应商IDin_datedate入库日期这里再次强调stock_batch表里的purchase_price字段存的是这一批货的实际进货价不能直接引用drug表的统一进货价。进了两次货、两次价格不一样销售毛利分析就要精确到批次。采购单主表purchase_order记录order_no采购单号、supplier_id、total_amount、status1待审核2已入库3已作废、create_time、audit_time。采购单明细表purchase_order_item记录purchase_order_id、drug_id、batch_no、expire_date、quantity、purchase_price、amount。采购入库后系统按明细逐条向stock_batch插入批次记录同时把采购单状态改为已入库。这个动作必须是事务性的明细插入批次、主表更新状态要么都成功要么都失败。4.2 采购入库的Service实现思路与事务写法贴一段简化后的核心Service代码帮助你把上面的逻辑串起来。实际项目里还会有DTO转换、异常分支、权限校验这里先保留主干Service RequiredArgsConstructor public class PurchaseOrderService { private final PurchaseOrderMapper purchaseOrderMapper; private final PurchaseOrderItemMapper purchaseOrderItemMapper; private final StockBatchMapper stockBatchMapper; Transactional(rollbackFor Exception.class) public void auditAndInbound(Long orderId) { PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (order null || order.getStatus() ! 1) { throw new BusinessException(订单不存在或已处理); } ListPurchaseOrderItem items purchaseOrderItemMapper.selectByOrderId(orderId); for (PurchaseOrderItem item : items) { StockBatch batch new StockBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(item.getBatchNo()); batch.setExpireDate(item.getExpireDate()); batch.setQuantity(item.getQuantity()); batch.setPurchasePrice(item.getPurchasePrice()); batch.setSupplierId(order.getSupplierId()); batch.setInDate(LocalDate.now()); stockBatchMapper.insert(batch); } order.setStatus(2); purchaseOrderMapper.updateById(order); } }几个关键点展开说。第一Transactional的rollbackFor一定要写Exception.class。默认情况下Spring只对运行时异常RuntimeException回滚如果你的BusinessException继承的是Exception而不是RuntimeException那事务默认不会回滚——到时候数据改了一半你根本不知道。第二入库时把采购单价写到批次表为后续销售毛利分析、退货结算做准备。第三主表上的审核人、审核时间这些审计字段实际项目里一定要落库答辩时老师问怎么保证采购审批合规这就是你的答案。4.3 销售出库的核心校验库存、批号、效期、处方药销售端Service的核心逻辑我按伪代码来写重点是流程而不是语法细节Transactional(rollbackFor Exception.class) public void createSaleOrder(SaleOrderCreateDTO dto) { ListStockBatch batches stockBatchMapper.selectAvailableBatches(dto.getDrugId()); // 1. 校验总库存是否充足 int total batches.stream().mapToInt(StockBatch::getQuantity).sum(); if (total dto.getQuantity()) { throw new BusinessException(库存不足); } // 2. 按效期由近到远先出库FEFO先到期先出 batches.sort(Comparator.comparing(StockBatch::getExpireDate)); int need dto.getQuantity(); for (StockBatch batch : batches) { if (need 0) break; int used Math.min(batch.getQuantity(), need); stockBatchMapper.decreaseStock(batch.getId(), used); saleOrderItemMapper.insert(...); // 登记销售明细带上批次ID need - used; } // 3. 处方药强制登记 if (dto.getPrescriptionFlag()) { prescriptionMapper.insert(dto.getPrescriptionInfo()); } }这里面的FEFOFirst Expired First Out先到期先出策略是整个系统的灵魂算法。普通库存管理讲FIFO先进先出但药品必须按效期出库——因为先入的批次可能还没过期后入的批次反而先过期你不能看着后入的过期在库里。把FEFO写进设计文档并且在代码里实现出来你的毕设深度立刻和CRUD堆砌拉开差距。注意selectAvailableBatches这条SQL必须在查询条件里过滤掉已过期批次expire_date CURRENT_DATE这一步很容易漏漏掉的后果就是过期药品还能卖我下面会专门讲。4.4 效期预警定时任务用SpringBoot自带的Scheduled实现预警逻辑用SpringBoot自带的Scheduled就能解决不需要引入Quartz等额外框架。写一个任务类Component public class ExpiryAlertTask { Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void checkExpiry() { ListStockBatch batches stockBatchMapper.selectExpiringSoon(180); for (StockBatch batch : batches) { // 生成预警记录前端轮询或WebSocket推送 alertMapper.insert(new ExpiryAlert(batch.getId(), batch.getExpireDate(), 近效期预警)); } } }两个细节。第一启动类上必须加EnableScheduling否则任务永远不会执行。第二cron表达式要注意时区问题——0 0 2 * * ?表示服务器当地时区的凌晨2点如果服务器是UTC时间实际在北京时间早上8点执行。调试的时候你以为任务没跑其实它只是在另一个时间点跑了。5. 实测踩坑记录批次效期、事务回滚和定时任务是最容易翻车的三个地方5.1 坑一过期批次还挂在可用库存里系统照卖不误这是我评审毕设时见过最高频的问题。很多同学做了效期预警表首页也确实显示了XX药品即将过期但打开销售页面一搜这款过期药照样能下单。根因就是销售Service里查询可用批次时没有过滤过期数据。排查思路很简单打开你的selectAvailableBatchesSQL确认 where 条件里有没有expire_date CURRENT_DATE或者等价写法。如果没写把过期批次过滤掉问题就解决了。这个坑的隐蔽性在于数据库里存的过期数据是你手工造的测试数据前端页面也只在预警列表里出现你根本没意识到销售逻辑会把它捞出来。所以写完销售模块务必用一条已过期批次的数据实测一遍确认它不能出库。5.2 坑二Transactional看起来生效了其实压根没管用再讲一个高频翻车点事务失效。Spring的Transactional默认只对public方法生效而且同类内部通过this调用时不经过代理事务直接失效。比如你在SaleService的createSaleOrder方法里调用了同一个类里的this.checkPrescription()你以为这两段代码在一个事务里实际上不是——checkPrescription如果抛异常createSaleOrder里已执行的数据库操作不会回滚。解决办法有三种把checkPrescription拆到一个独立的Service里由Spring容器注入再调用或者把校验逻辑直接写在createSaleOrder方法体内最不推荐的是用AopContext.currentProxy()拿当前代理对象再调用代码可读性差还容易踩坑。我的建议是拆分Service既解决事务问题职责边界也更清晰。5.3 坑三定时任务本地跑得好好的打包部署后就是不执行常见原因就两个。第一个是启动类没加EnableScheduling。第二个是任务类没被组件扫描扫到——如果你的定时任务类放在启动类所在包之外SpringBoot默认的扫描范围覆盖不到它这个类根本不会被实例化自然也就没有调度。排查时先把任务类挪到启动类所在包的子包下再确认EnableScheduling已加基本就能解决。还有一个容易被忽略的点部署到服务器后任务类的Class文件如果没被打进jar包也会静默失败。所以打包前检查一下target目录里有没有这个类。5.4 坑四MySQL的CURRENT_DATE和Java的LocalDate.now()对不上效期判断错乱这个坑最隐蔽我放在最后讲。MySQL的CURRENT_DATE返回的是数据库服务器所在时区的当前日期Java的LocalDate.now()返回的是JVM默认时区的当前日期。如果数据库服务器时区是UTCJVM时区是Asia/Shanghai那么每天零点前后几个小时两边拿到的今天不是同一天效期判断就会出错——比如批次的expire_date恰好是今天MySQL认为还没过期、Java认为已经过期或者反过来。解决方案很简单数据库连接串上统一设置时区比如jdbc:mysql://localhost:3306/pharmacy?serverTimezoneAsia/Shanghai同时JVM启动参数加上-Duser.timezoneAsia/Shanghai两边保持一致。这条经验虽然不深但实际项目里为这个折腾半天的真不少。6. 答辩要点老师最喜欢追问的五个问题以及怎么回答得漂亮答辩老师基本不会一行行读代码他在你演示完功能后追问的几个问题往往用来判断两件事项目是不是你自己做的你对系统是不是真理解。我结合带毕设的经验整理五个高频追问和应对思路。6.1 追问一为什么用SpringBoot不用SSH或SSM回答思路分三点。配置简化自动配置和Starter机制搭建成本大幅降低不需要手工维护一堆XML。生态完善Web、MyBatis、Redis、Security都有官方Starter集成成本低。部署简单内嵌容器java -jar直接运行部署工作量几乎为零。有余力可以补一句SpringBoot本质还是基于Spring框架IOC/AOP核心思想没变它只是简化了使用方式降低了配置负担。这句话能体现你对底层原理有概念而不是只会套框架。6.2 追问二药品批次和库存怎么关联同一种药品不同批次怎么办回答分两层库存拆分——stock_batch表按批次存数量同一个drug_id可以对应多条批次记录出库策略——销售出库时按效期由近到远扣减批次库存保证先到期先出。如果老师继续追问采购入库时同一批号录两遍怎么办可以回答按批号有效期联合唯一约束或者允许同批号分多条记录并给出警告提示。讲到这里老师基本就满意了。6.3 追问三效期预警是怎么实现的答用SpringBoot的Scheduled定时任务每天扫描stock_batch表找出expire_date在当前日期加N天内的批次生成预警记录。前端通过轮询接口读取预警表在首页以提示卡片或数字徽标展示。如果老师追问为什么用定时任务而不是实时计算可以答实时计算每次打开列表页都要全表扫描查询性能差定时任务预先算好结果存下来查询时直接读预警表响应更快。这个回答把性能考虑说清楚了比直接背实现步骤更有说服力。6.4 追问四多个门店之间的库存是共享还是隔离这个问题可深可浅取决于你的设计。简单方案是库存集中在总部门店之间通过调拨单实现库存转移复杂方案是每个门店独立库存门店之间互不可见。毕设里建议选前者理由很简单表结构相对简单演示直观逻辑链路短。回答时重点说明调拨单是连接门店库存的桥梁调拨单审核通过后调出门店库存减少、调入门店库存增加这两步在同一个事务里完成。能讲到事务层面说明你是真考虑过并发和数据一致性。6.5 追问五系统的安全性怎么保证密码是明文存储的吗这个问题属于常识题答不上来特别减分。核心回答是密码不能明文存储使用BCryptSpring Security自带的BCryptPasswordEncoder做哈希登录时比对哈希值不做反解。接口层面用spring-boot-starter-validation做参数校验防止恶意输入。最后补一句后续可以扩展Spring Security JWT做完整的认证授权体系。这既回答了现状也显示出你有后续规划意识比干巴巴答用了MD5强太多。7. 经验收尾写完这套系统我最想告诉你的三件事第一件事尽早统一异常处理。我最早写功能时图省事每个Controller里try-catch后直接return Result.error(系统错误)后来翻日志才发现大量真实异常被吞掉了查起问题来两眼一抹黑。后来统一封装了一个RestControllerAdvice全局异常处理器把业务异常、参数校验异常、数据库异常分开处理代码量降下来不说日志里终于能看到真正的错误堆栈了。毕设里哪怕只写一个简单的全局异常处理器也比散落一地的try-catch看起来专业得多。第二件事前端列表的分页、搜索、排序参数从一开始就按规范来。我最早是把page、size、keyword直接塞在接口参数里后来查询条件组合越来越多接口签名被改得面目全非。改成统一的分页请求DTO之后前端所有列表页复用同一套逻辑后期加筛选条件只需要往DTO里加字段。毕业设计的开发周期就那么长能复用的地方就不要重新发明轮子。第三件事本地写代码时日志级别一定要打开到能看见SQL。我见过不少同学把application.yml里的root日志调成INFO然后发现MyBatis的SQL不打印报错堆栈不完整排查问题只能靠猜。我的习惯是开发环境把logging.level.com.example.mapperDEBUG生产环境用INFO。这样每天调试省下的时间比你想象得多得多。对于正在做基于SpringBoot的药品供销/进销存系统的同学我的最后一条建议是先把表结构定稳再按采购→销售→库存→预警这条主线实现最后回头补报表和权限。不要一上来就做权限系统也不要一开始就死磕前端样式。先让业务闭环跑通你才有底气去应付后面所有的问题——包括老师那些让人猝不及防的追问。