SpringBoot药店供销系统:批次效期管理与供应链协同实践
做过几个SpringBoot的进销存项目之后再看“森伯药店供销系统”这个题目我得说它比表面看起来要难不少。药店业务和普通超市进销存最大的不同在于商品有批号、有效期的强约束处方药有管控要求库存账不仅要数量对得上批号和效期也得对得上。这套系统的核心是基于SpringBoot的一套医药零售进销存管理平台往大了说就是智慧药房供应链协同系统。本文从需求拆解、技术选型、数据库设计、核心模块实现到上线遇到的一堆坑完整梳理一遍给正在做类似毕设或刚接手医药类项目的同学一个参考。1. 项目定位药店供销不是普通进销存1.1 药店业务的三条主干线采购、库存、销售先把这个系统“要解决什么问题”说清楚。药店供销系统业务主干线就是采购、库存、销售听上去和便利店系统没什么区别但实际建模难度完全不一样。普通商品的进销存一个商品一条库存记录数量加减就行药品不行药品管理的最小单位是“批次批号”而不是商品本身。举个例子你进了两批阿莫西林一批是2024年3月生产的效期到2026年2月另一批是2024年8月生产的效期到2026年7月。这两批药放在货架上零售价可能一样但进货价、供应商、效期完全不同。库存在系统里不能只记录“阿莫西林还有50盒”必须精确到每一批有多少盒哪个批号先到期。这就是医药零售特有的“批号管理效期管理”。所以采购入库时要做批次拆分销售出库时要按先进先出或近效期先出规则自动分配批次盘点时要按批号盘点。库存表的设计、流水记录的方式和普通ERP的系统很不一样。1.2 为什么说合规流程是这个项目的灵魂大部分毕设项目把功能做完就觉得“可以了”但药店系统最大的门槛在合规。药品经营是有专门的管理规范约束的不是随便记个流水账就能开药店。我当初做这个项目时指导老师没有细讲结果我第一版做出来就是一个普通进销存被一句“不合规”打回来重做。当时不理解后来想明白了这个系统能不能在真实药房用起来关键不在CRUD而在流程是否满足GSP要求。具体到功能上合规要求驱动的需求包括首营企业、首营品种审核记录新供应商、新品种要录入资质信息审核通过才能采购。近效期预警药品到期前6个月、3个月、1个月要有不同级别的提醒。处方药销售登记凭处方销售处方信息要记录销售时要有药师复核环节。温湿度记录冷链药品和常温药品的存储环境需要记录便于追溯。操作日志留存谁在什么时间做了什么操作可追溯、不可抵赖。把这些落成系统功能后项目的深度一下子就上来了。面试官或答辩老师问起来这些就是“业务理解”的加分项。1.3 供应链协同从“记库存”到“管供应”再看标题里的“供应链协同系统”。很多毕设把这三个字理解成花架子其实不是。药店的供应链协同核心就两件事缺货要能及时补近效期要能及时催销。我设计的时候加了三个联动逻辑库存低于安全库存时系统自动生成采购建议单。近效期药品库存高于可销售天数时系统标记“催销”状态在收银台按效期优先出库。采购订单审核通过后供应商到货的过程有个“在途”状态避免刚下采购单又继续报缺货。这些逻辑让“供应商-仓库-门店-顾客”之间形成一条完整的链路。系统不再是一个记录工具而是一个能辅助决策的工具。这也是“智慧药房”这个名字的实际含义。2. 技术选型与工程搭建SpringBoot做核心骨架2.1 SpringBoot版本选择的现实考量按题目的要求后端框架肯定是SpringBoot。但选择哪个版本是一个值得认真考虑的问题。我建项目的时候SpringBoot 3.x已经推出了可我还是选了2.7.18原因有三个JDK兼容性、生态稳定性和资料丰富度。毕设环境下不少人的JDK版本是1.8。SpringBoot 3.0开始最低要求JDK 17如果你电脑上还是8那就要么换JDK、要么挺多第三方库的旧版本用不了。而SpringBoot 2.7.x是2.x的最终版本稳定资料多网上随便一搜就是现成的方案用来做毕设或者生产上中小型系统都够用。SpringBoot 2.7.18 JDK 1.8 MyBatis-Plus 3.5.x MySQL 8.0 Redis 6.x这套组合我用了很多次属于“不出错”组合。如果非要用SpringBoot 3.x记得同时把JDK升到17并且注意MyBatis-Plus要用3.5.3以上版本否则有兼容性问题。2.2 工程结构与核心依赖清单我习惯用优雅一点的分层结构不是传统的controller/service/mapper三层到底而是按业务域分包com.senbo ├── common # 通用工具、统一返回、异常处理 ├── config # SpringBoot配置、MyBatis-Plus配置 ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # 数据访问层 ├── entity # 实体类 ├── dto # 请求/响应对象 └── task # 定时任务效期预警、缺货预警依赖方面pom.xml中核心的几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency前端我选的是Vue 2 Element UI用前后端分离开发。SpringBoot只负责提供RESTful API前端用axios请求数据。毕设答辩时前后端分离这个点本身就是可以讲的技术亮点。2.3 数据库设计的几个关键模型数据库设计是这个项目的重中之重。核心表主要有这样几张表名作用关键字段product_info商品档案product_code, product_name, spec, manufacturer, approval_numberproduct_batch批次库存表product_id, batch_no, production_date, expire_date, quantity, purchase_pricesupplier_info供应商档案supplier_name, license_no, contact_person, phonepurchase_order采购订单主表order_no, supplier_id, order_status, total_amountpurchase_order_item采购订单明细order_id, product_id, batch_no, quantity, price, expire_datestock_flow库存流水表product_id, batch_id, change_type, change_qty, before_qty, after_qty, biz_nosale_order销售订单主表order_no, member_id, total_amount, cashier_id, prescription_nosale_order_item销售订单明细order_id, product_id, batch_id, quantity, price, presc_statusinventory_check盘点单check_no, check_status, check_date, checker_idinventory_check_item盘点明细check_id, product_id, batch_id, stock_qty, actual_qty, diff_qty有几个细节说一下一个是product_batch和product_info是分开的批次库存独立成表。如果没有批次表效期预警根本无法实现。同一个商品有几个批号在批次表里就有几条记录数量是每批分开的。另一个是stock_flow库存流水表这张表决定了“账实一致”能不能追查。每一次库存变动无论入库、出库、盘盈、盘亏、报损都要写一条流水记录变动前后的数量。有了这张表什么时候库存不对了可以反向追踪到具体哪一笔业务出了问题。很多毕设的库存表只记录当前数量没有流水这个问题在答辩时比较容易暴露。加了流水表整个系统的数据可信度完全不一样。3. 核心功能实现进销存业务链路的完整闭环3.1 商品档案与近效期预警第一批要写的代码商品档案看起来就是增删改查实际上有几个字段容易忽略批准文号、生产厂家、规格、存储条件、剂型。这些字段不只是用来显示的后面做首营品种审核、分销、拦截都靠它们。最核心的功能是近效期预警。我在项目里写了一个定时任务每天扫描一次批次表根据到期日期和当前日期计算剩余天数然后按预警级别写入提醒表并给店长推送。关键的SQL大概是这样SELECT b.id, b.product_id, p.product_name, b.batch_no, b.quantity, b.expire_date, DATEDIFF(b.expire_date, CURDATE()) AS days_to_expire FROM product_batch b LEFT JOIN product_info p ON b.product_id p.id WHERE b.quantity 0 AND b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 180 DAY) ORDER BY b.expire_date ASC;这里有个细节预警不能只查大于0的批次已经报损或者已清零的批次不用再提醒。同时要加一个条件避免把已经卖完的批次反复提醒。我的做法是quantity 0。预警级别我分了三个等级180天内到期黄色预警列表里显示收银台正常销售。90天内到期橙色预警首页轮播提醒同时建议做促销。30天内到期红色预警限制整批出库给顾客特殊用药除外提示走报损或退货流程。实测下来的经验是效期预警这个定时任务不要写得太频繁每天一次就够。如果担心漏掉当天新入库的批次可以在入库接口里额外调一次效期检查两处配合就不容易漏。3.2 采购入库批次库存怎么建才不乱采购入库是整个库存链路的起点也是批次数据的来源。我发现很多新手在入库这里就容易出问题直接往库存表里加数量结果批号信息丢了后面效期管理、先进先出全部做不了。正确流程是采购订单创建 → 订单审核 → 到货验收 → 入库上架。采购订单创建时可以选择已有商品填写采购数量、单价、供应商信息。审核通过后系统生成待入库单。到货验收时要录入每个商品的生产批号和有效期。入库操作的代码逻辑我用事务包裹保证数据一致性Transactional(rollbackFor Exception.class) public void confirmInbound(PurchaseInboundDTO dto) { // 1. 校验采购订单状态 PurchaseOrder order purchaseOrderMapper.selectById(dto.getOrderId()); if (!APPROVED.equals(order.getOrderStatus())) { throw new BusinessException(当前订单状态不可入库); } // 2. 逐个明细更新批次库存 for (PurchaseOrderItemDTO item : dto.getItems()) { ProductBatch batch new ProductBatch(); batch.setProductId(item.getProductId()); batch.setBatchNo(item.getBatchNo()); batch.setProductionDate(item.getProductionDate()); batch.setExpireDate(item.getExpireDate()); batch.setQuantity(item.getInboundQty()); batch.setPurchasePrice(item.getPrice()); productBatchMapper.insert(batch); // 3. 写库存流水 writeStockFlow(item.getProductId(), batch.getId(), PURCHASE_IN, item.getInboundQty(), 0L, item.getInboundQty(), order.getOrderNo()); // 4. 更新商品总库存 productInfoMapper.increaseStock(item.getProductId(), item.getInboundQty()); } // 5. 订单状态改为已入库 order.setOrderStatus(INBOUND); purchaseOrderMapper.updateById(order); }这里有两个坑第一个入库数量和采购数量不一致时要有“差异处理”流程。实际到货可能比采购单少也可能破损几盒我的做法是把差异数量记在“验收差异备注”里并且可以选择转为报损单不直接改采购数量。第二个同一个商品同一天来了两个批号批次表就是两条记录不要合并合并了后面效期就乱了。3.3 前台销售收银并发扣减和批量退货收银过程是最容易出问题的环节。药店高峰期几个收银员一起卖同一批退烧药数据库MySQL默认的InnoDB行锁能保证同一时刻只有一个事务能更新同一行数据但如果业务代码不做限制还是可能发生“超卖”或“库存负库存”。原因在于很多新手写扣库存是这样的SELECT quantity FROM product_batch WHERE id ?判断 quantity 销售数量UPDATE product_batch SET quantity quantity - ? WHERE id ?这个写法在并发时两个线程同时拿到了相同的quantity都判断“够卖”然后各自减自己的数最后库存可能变成负数。解决办法是让UPDATE自己判断条件int updated productBatchMapper.reduceStock(batchId, saleQty); if (updated 0) { throw new BusinessException(库存不足或批次已失效); }reduceStock的SQL是UPDATE product_batch SET quantity quantity - #{saleQty} WHERE id #{batchId} AND quantity #{saleQty}这一步“条件更新”会把并发问题挡在数据库层面。affected rows等于0就说明扣减失败不用再走业务判断。这个写法我强烈建议直接用。销售出库时按“近效期先出”来分配批次。这里有个细节很多人以为先进先出FIFO就够了其实药品行业更合理的规则是“近效期先出FEFO”。效期最近的批次先卖防止过期损耗。我在分配批次时把销售数量拆成多条出库明细一个批次不够就拆到下一个批次这样库存流水永远清晰。3.4 盘点报损与库存调整账实不一致的兜底方案无论系统做得多严谨实物和账本总有对不上的时候。这时候盘点流程就是最后的兜底手段。盘点大概分三步创建盘点单选库房或者货架区域生成盘点单。录入实际数量拿着系统生成的盘点表去数实物然后在系统里录入实盘数。一般做成按商品批次录入。审核盘点差异系统自动对比账面数量和实际数量算出差异。审核通过后差异自动生成盘盈或盘亏流水调整库存。盘点差异的处理逻辑if (actualQty stockQty) { // 盘盈增库存写盘盈流水 adjustmentType INVENTORY_PROFIT; diffQty actualQty - stockQty; } else if (actualQty stockQty) { // 盘亏减库存写盘亏流水 adjustmentType INVENTORY_LOSS; diffQty stockQty - actualQty; }我实际做的时候还加了一个“复盘”机制如果差异数量超过某个阈值比如单品差异超过10盒系统会阻止直接审核提示先复盘。这是真实药房常见的管理规范写进系统里会显得业务逻辑成熟。报损流程相对简单一般是效期过期、破损、召回药品填一张报损单注明原因审核后扣减对应批次的库存。4. 权限控制与运营细节让系统真正能落地4.1 RBAC权限模型员工、店长、采购员各干各的药店系统不能所有人都能改库存、看成本、审订单。权限控制我用的是经典的RBAC基于角色的访问控制模型一共设计了四个角色角色核心权限系统管理员用户管理、权限配置、数据字典、系统设置店长采购审核、盘点审核、报损审核、报表查看、近效期处理药师处方药审核、用药咨询记录、处方登记收银员前台开单、销售退货、会员查询、库存查询权限控制的落地方式是SpringBoot拦截器 自定义注解。拦截器先校验Token再通过反射获取接口上的权限注解校验当前用户是否有对应权限。核心代码示意Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String value(); }接口上这样标注RequiresPermission(purchase:audit) PostMapping(/purchase/audit) public Result auditPurchase(RequestBody AuditDTO dto) { ... }之所以不用Spring Security是考虑到整个系统只需要一个很轻量的权限判断不需要复杂的OAuth2流程。用拦截器加注解代码量少逻辑直观答辩也好讲。但如果你希望项目看起来更“企业级”引入Spring Security JWT也不是不行就是复杂度上去了后面调试会比较痛苦。4.2 处方药与特殊药品流程的自动拦截处方药销售是药店区别于普通零售的另一个大业务点。系统里要有“处方登记”和“药师复核”两个动作。我的设计是药品档案里有个字段prescription_type值为0是非处方药1是处方药2是特殊药品比如含麻黄碱类复方制剂。收银台提交订单时如果订单中包含处方药系统自动校验必须填写处方的编号信息或上传处方图片。必须选择复核药师药师信息存在订单表里。特殊药品类商品单个订单的购买数量不能超过一个最高限额。如果这些条件不满足订单提交按钮是禁用的。这一步把合规要求直接嵌到业务流里而不是靠事后提醒。处方药销售订单和普通订单分开存储单独一个sale_order_prescription表里面记录处方编号、医院名称、医师姓名、药师姓名、销售时间。这个表的数据是将来应对合规检查时最重要的追溯依据。4.3 缺货预警与供应商协同把供应链盘活前面说了供应链协同系统核心落地点是缺货预警和采购建议。我在设计库存模型时给每个商品加了两个字段安全库存safety_stock和采购周期lead_time。每天定时任务扫描时用“当前总库存 在途库存 - 安全库存”算出一个可售天数。当可售天数小于采购周期天数就生成一条缺货预警记录。采购建议单的生成逻辑更进一步结合近30天该商品的平均日销量估算补货量。补货量 平均日销量 × 采购周期天数 安全库存 - 当前总库存 - 在途库存举个例子某感冒药过去30天平均每天卖5盒采购周期是3天安全库存是20盒当前库存是15盒没有在途订单。补货量 5×3 20 - 15 - 0 20盒。这个公式很简单但自动化之后系统会每天生成一张采购建议单采购员只需要审核调整后转成采购订单。这个功能在答辩演示时显得非常“高智商”因为你的系统会主动提出采购要求而不是等着人来录单子。5. 部署上线与高频问题排查实录5.1 MyBatis-Plus分页插件不生效分页是进销存系统里最高频的功能几乎每个列表都要分页。MyBatis-Plus分页插件不生效是新手必踩的坑。具体现象是分页查询返回的数据不是当前页而是全量数据。查看日志发现执行的SQL语句后面没有LIMIT。原因几乎是固定的没有配置分页插件拦截器。别人给你的代码片段里只有Mapper的BaseMapper接口没有把PaginationInnerInterceptor注册到MyBatisPlusInterceptor里。正确配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注意分页逻辑上还有一个问题列表查的是product_batch表但页面要显示product_name、manufacturer等信息。我是在商品列表用分页查商品主档批次信息用子查询或者关联查询带出来尽量避免分页后再逐条查关联表。5.2 BigDecimal金额精度丢失金额计算必须用BigDecimal这个大家都知道。但很多人不知道的是把前端传过来的字符串转BigDecimal时如果用了new BigDecimal(doubleValue)就有精度问题。正确的姿势是new BigDecimal(String value)或者用BigDecimal.valueOf(double)。另外一个坑是累计金额时如果用Double去累加多位小数计算后可能会出现0.30000000000000004这种结果。我的建议是所有涉及金额、数量、价格的计算一律用BigDecimal并且统一保留两位小数入库时setScale(2, RoundingMode.HALF_UP)。还有一个数据库字段设计的问题金额字段不要用float或double类型应该在MySQL里直接用decimal(10,2)数量字段用decimal(12,3)或int根据单位来定。如果你设计的表里金额字段是double改掉不然后面算账对不上。5.3 高并发下库存扣减超卖这个问题的解决办法在前面销售环节里已经说了用条件UPDATE代替“先查后改”。再补充一个点加了条件更新之后索引也很重要。product_batch表的主键是id扣减是按batch_id来做的所以batch_id上要有索引。如果系统将来要做大并发可以在Redis里预扣库存异步同步到MySQL。但毕设或者中小型药店系统直接用MySQL的原子更新 事务就够了没必要引入分布式锁这套复杂度。过度设计在毕业设计里反而是减分项。5.4 热部署、端口占用与其他环境坑开发环境里最常见的坑有三个。第一个是热部署不生效。pom里引入了spring-boot-devtoolsidea里也勾了自动编译但页面改了不生效。排查方法是看idea的“Build project automatically”有没有打开以及Registry里的compiler.automake.allow.when.app.running有没有勾选。再不行就手动CtrlF9编译一次。第二个是端口被占用。SpringBoot默认8080如果你电脑上跑着其他服务启动的时候会报Port 8080 was already in use。解决方式是在application.yml里换个端口或者杀掉占用进程。第三个是MySQL时区问题。连接串上要加serverTimezoneAsia/Shanghai否则会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或者日期字段差8个小时。日期时间字段统一用DATETIME类型Java实体用LocalDateTime不要用java.util.Date避免格式化和时区问题叠buff。最后分享一点个人体会把SpringBoot药店供销系统完整做下来我最大的感受是真正拉开两个项目差距的不是用了多少新技术而是能不能理解行业规则并把规则变成看得见的功能逻辑。同样是进销存普通系统的batch_no可能只是个字符串而在药店系统里它是库存追溯的线索是效期管理的基础是销售分配的依据。不要看到“毕业设计”就觉得可以随便做套CRUD把合规、批次、效期、供应链协同这些点做实了这个项目拿出来说是有底气的。如果你现在正在做类似的系统建议先理清业务流程从批次库存表开始建模然后按“采购入库-销售出库-盘点调整-预警协同”这条主线一步一步往前推你会发现整个系统的复杂度是可控的。