☰
Java医药进销存管理系统源码:批号效期追溯与GSP合规设计
2026/10/8 4:10:10 网站建设 项目流程

简介:这是一套基于Java的医药进销存管理系统完整源码,面向计算机专业毕业设计学生、Java Web初学者及中小型药店、医院药房的技术开发者,帮助解决药品采购、销售、库存等业务自动化管理问题。压缩包共944个文件,约8.32MB,以html、css、js等前端页面文件为主,配合105个java源文件、6个sql脚本及xml、properties等配置,另有少量php文件,整体结构清晰,便于按模块研读。系统采用MVC设计模式,涉及Spring、Hibernate、Servlet与JSP等技术,涵盖药品入库、出库、库存查询、销售记录跟踪等核心模块,并包含报表统计与安全校验思路。目前已有328人学习下载,适合作为毕业设计参考或二次开发基础,读者可借此理解企业级Java Web应用的分层架构、数据库交互与前后端协作方式,积累完整的项目实战经验。

1. 医药进销存管理系统源码:从一张 GSP 批号表说起

做过医药流通的都知道,普通商品进销存和医药进销存根本不是一个东西。普通商品看的是数量、金额、库存周转;医药这边,一盒药从入库到出库,中间要盯住批号、生产日期、有效期、批准文号、供货商资质、随货同行单,少一样就是 GSP 飞检的硬伤。我见过太多团队拿通用 ERP 改医药模块,最后卡在「同一药品不同批号必须分开管理」这一条上,库存对不上,追溯链断裂,返工重写。

这份「基于 Java 的医药进销存管理系统源码」要解决的就是这件事:用 Java 技术栈把药品的批号级进销存、效期预警、资质档案、采购销售退货流程串成一条可追溯的链路。它适合三类人——想拿它做课程设计或毕业设计的在校生、需要快速搭一套中小医药批发企业进销存的中小团队、以及想通过读源码理解「业务约束如何落到数据库和代码」的 Java 工程师。下面我不讲空泛架构,直接拆这套系统该怎么读、怎么跑、参数怎么设、哪里最容易翻车。

2. 医药进销存的核心数据模型:批号、效期与库存三张表怎么设计

2.1 为什么药品库存不能只存一个数量

普通进销存的库存表通常就是sku_id + quantity,简单直接。医药场景下这张表会直接失效,因为同一个drug_id下可能同时存在三个批号,每个批号的有效期不同,出库时必须按「先进先出、近效期先出」的原则扣减。如果只存一个总数量,你根本不知道扣的是哪个批号,追溯就断了。

所以医药进销存的最小库存单元是「药品 + 批号」,库存表的主键或唯一索引必须落在(drug_id, batch_no, warehouse_id)上。这不是设计偏好,是 GSP 追溯的硬要求。读源码时第一件事就是找库存表,看它的唯一约束是不是这么建的。如果源码里库存表只有drug_id一个维度,那这套代码在医药场景下是不完整的,你得自己补批号维度。

2.2 三张核心表的字段与约束

下面是我从这类系统里提炼出的核心表结构,字段名做了通用化处理,你对照源码时按这个思路核对即可。

-- 药品基础信息表:一个药品一条记录,不含批号 CREATE TABLE drug ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL COMMENT '药品编码,内部唯一', drug_name VARCHAR(128) NOT NULL COMMENT '通用名', approval_no VARCHAR(64) NOT NULL COMMENT '批准文号,如国药准字Hxxxx', spec VARCHAR(64) COMMENT '规格,如0.25g*24片', manufacturer VARCHAR(128) COMMENT '生产厂家', is_prescript TINYINT DEFAULT 0 COMMENT '是否处方药', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', UNIQUE KEY uk_drug_code (drug_code) ) COMMENT '药品基础信息'; -- 库存批次表:批号级库存,唯一约束是追溯的关键 CREATE TABLE stock_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) NOT NULL COMMENT '生产批号', produce_date DATE COMMENT '生产日期', expire_date DATE NOT NULL COMMENT '有效期至', warehouse_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0 COMMENT '当前批次库存', cost_price DECIMAL(12,4) COMMENT '批次成本价', UNIQUE KEY uk_drug_batch_wh (drug_id, batch_no, warehouse_id), KEY idx_expire (expire_date) ) COMMENT '批号级库存';

uk_drug_batch_wh这个唯一索引是整个系统的地基。idx_expire是给效期预警查询用的,没有它,每天扫描近效期药品会全表扫。expire_date设为NOT NULL是因为医药入库时有效期是必填项,允许为空等于放弃效期管理。

2.3 入库单与出库单如何与批次挂钩

采购入库和销售出库的明细行,都必须落到具体批号上。入库时批号由供货商随货同行单提供,出库时由系统按规则自动分配。常见做法是出库明细先不指定批号,由库存分配逻辑按「近效期优先」挑批次,再回写实际扣减的批号。

// 出库批次分配:近效期优先,同效期先进先出 public List<StockAllocation> allocate(Long drugId, Long warehouseId, int needQty) { List<StockBatch> batches = stockBatchMapper.selectAvailable(drugId, warehouseId); // selectAvailable 内部 ORDER BY expire_date ASC, produce_date ASC List<StockAllocation> result = new ArrayList<>(); int remain = needQty; for (StockBatch b : batches) { if (remain <= 0) break; int take = Math.min(b.getQuantity(), remain); result.add(new StockAllocation(b.getId(), b.getBatchNo(), take)); remain -= take; } if (remain > 0) { throw new BizException("库存不足,缺口 " + remain); } return result; }

这段逻辑的关键在selectAvailable的排序:expire_date ASC保证近效期先出,produce_date ASC在同效期时保证先进先出。参数上要注意,quantity的扣减必须和分配在同一个事务里,并且对stock_batch行加锁(SELECT ... FOR UPDATE),否则并发出库会超卖。我一般会在selectAvailable里直接带FOR UPDATE,虽然锁范围大一点,但医药出库并发量通常不高,正确性优先。

3. 用 Spring Boot + MyBatis-Plus 把源码跑起来的最小步骤

3.1 环境准备与依赖版本

这类 Java 医药进销存源码主流是 Spring Boot + MyBatis-Plus 组合,前端可能是 Vue 或 Thymeleaf。跑起来之前先把环境对齐,版本不匹配是启动失败的头号原因。

组件建议版本说明
JDK8 或 11源码若用var或 record 则需 11+
MySQL5.7 或 8.0注意 8.0 驱动类名带cj
Maven3.6+用于拉依赖和打包
Spring Boot2.x看 pom 里 parent 版本
MyBatis-Plus3.x与 Boot 版本对应

先看pom.xml里的spring-boot-starter-parent版本,再决定 JDK。如果源码是 Boot 2.7 + JDK 8,你硬上 JDK 17 大概率会在反射或字节码增强处报错。

3.2 数据库初始化与配置修改

源码一般会带一个sql目录,里面有建表脚本和初始数据。导入顺序是先建库、再执行表结构、最后插初始数据。

# 1. 建库,字符集用 utf8mb4,医药名称常有生僻字 mysql -uroot -p -e "CREATE DATABASE medicine_erp DEFAULT CHARSET utf8mb4;" # 2. 导入表结构与初始数据 mysql -uroot -p medicine_erp < sql/schema.sql mysql -uroot -p medicine_erp < sql/data.sql # 3. 确认关键表已建好 mysql -uroot -p medicine_erp -e "SHOW TABLES; SELECT COUNT(*) FROM drug;"

导入后重点确认stock_batch表的唯一索引是否真的建上了,有些源码的 schema 里漏了uk_drug_batch_wh,这会导致后续批号库存可以重复插入,是隐蔽的坑。

然后改application.yml:

spring: datasource: url: jdbc:mysql://localhost:3306/medicine_erp?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true

serverTimezone必须显式指定,否则 MySQL 8.0 下日期字段可能差 8 小时,效期计算直接错一天。map-underscore-to-camel-case打开后,数据库的expire_date才能映射到实体的expireDate。

3.3 启动与首个接口验证

# 打包并启动 mvn clean package -DskipTests java -jar target/medicine-erp-*.jar --spring.profiles.active=dev

启动日志里看到 Tomcat started on port 8080 就算起来了。接着验证登录和药品列表接口,确认数据库连通、MyBatis 映射正常。如果启动报Invalid bound statement,八成是mapper-locations路径没配对,或者 XML 没被打进 jar。用mvn package后解压 jar 看BOOT-INF/classes/mapper下有没有 XML 文件即可确认。

4. 效期预警与批号追溯:两个必须自己补的模块

4.1 效期预警的定时任务与阈值参数

很多医药进销存源码只做了基础进销存,效期预警要么没有,要么写死一个 90 天。实际业务里,近效期阈值应该可配置,而且不同类别药品阈值不同。我一般会加一张配置表或直接用字典。

// 每天凌晨 2 点扫描近效期批次 @Scheduled(cron = "0 0 2 * * ?") public void scanNearExpire() { int warnDays = configService.getInt("expire.warn.days", 90); LocalDate deadline = LocalDate.now().plusDays(warnDays); List<StockBatch> list = stockBatchMapper.selectNearExpire(deadline); for (StockBatch b : list) { // 已存在未处理的预警则跳过,避免重复推送 if (warnMapper.existsUnhandled(b.getId())) continue; warnMapper.insert(buildWarn(b, deadline)); } }

expire.warn.days默认 90,但生物制品、疫苗类往往要 180 天甚至更早预警,所以做成配置。selectNearExpire的 SQL 要带上quantity > 0,否则已出完的批次还会被扫出来。existsUnhandled这个去重判断不能省,否则每天重复插入预警,运营看到一堆重复消息会直接忽略,预警就形同虚设。

4.2 批号追溯查询的正向与反向链路

追溯分两个方向:正向是从一个批号查它卖给了哪些客户,反向是从一个客户投诉查他买的药来自哪个批号、哪个供货商。这两个查询都依赖入库单、出库单明细里存了batch_id。

-- 反向追溯:从批号查完整链路 SELECT sb.batch_no, sb.produce_date, sb.expire_date, inb.bill_no AS in_bill, inb.supplier_name, outb.bill_no AS out_bill, outb.customer_name, od.quantity FROM stock_batch sb LEFT JOIN stock_in_detail id ON id.batch_id = sb.id LEFT JOIN stock_in_bill inb ON inb.id = id.bill_id LEFT JOIN stock_out_detail od ON od.batch_id = sb.id LEFT JOIN stock_out_bill outb ON outb.id = od.bill_id WHERE sb.batch_no = #{batchNo};

这条 SQL 能跑通的前提是stock_in_detail和stock_out_detail都有batch_id字段。如果源码里出库明细只存了drug_id没存batch_id,追溯链就断在出库环节,这是读源码时必须优先确认的点。发现缺失就得在出库分配逻辑里补回写。

5. 避坑与排查:医药进销存源码落地时最容易翻车的 5 个点

5.1 库存超卖:并发出库时批号数量扣成负数

现象是压测或实际使用中stock_batch.quantity出现负数,或者两个订单扣了同一批货。原因是分配批次时用了普通SELECT,没加行锁,两个事务同时读到相同余量。解决是把selectAvailable改成SELECT ... FOR UPDATE,并把「查询批次 + 扣减库存 + 写明细」放在同一个@Transactional方法里。注意事务方法必须是 public 且通过 Spring 代理调用,类内部自调用事务不生效,这是血泪经验。

5.2 效期计算差一天:时区与日期类型混用

现象是预警列表里有效期当天或前一天的药没被扫出来。原因是java.util.Date和LocalDate混用,加上 JDBC 时区没配,日期在存取时偏移。解决是统一用LocalDate存有效期,JDBC URL 显式加serverTimezone=Asia/Shanghai,比较时用!expireDate.isAfter(deadline)而不是before,边界包含当天。

5.3 批号唯一索引缺失导致重复入库

现象是同一药品同一批号在库存表里出现两条记录,库存对不上。原因是 schema 里没建uk_drug_batch_wh,或者入库时没做「存在则累加、不存在则插入」的判断。解决是补唯一索引,入库逻辑改成先selectByUnique再决定 insert 还是 update,用INSERT ... ON DUPLICATE KEY UPDATE也可以,但要注意它返回的影响行数语义。

5.4 资质档案过期未拦截采购

现象是供货商资质已过期,系统仍允许对其下采购单。原因是资质校验只在前端做,后端接口没校验。解决是在采购单保存的 service 层加校验,查供货商的营业执照、GSP 证、法人授权书的有效期,任一过期直接抛业务异常。这个校验必须放后端,前端校验只是体验,不是防线。

5.5 启动报错 Invalid bound statement

现象是启动成功但调接口时报 MyBatis 找不到映射。原因是mapper-locations路径不对,或 XML 文件没被 Maven 打进 jar。解决是确认application.yml里路径为classpath*:/mapper/**/*.xml,打包后解压 jar 检查 XML 是否存在。如果 XML 放在src/main/java下,还要在 pom 的resources里配置包含**/*.xml,否则默认不会打包。

6. 进阶:用 MyBatis-Plus 代码生成器快速扩展药品与客户模块

这套源码跑通后,你大概率要按自己业务加表,比如增加「客户资质」「冷链温控记录」。手写 entity、mapper、service、controller 太慢,MyBatis-Plus 的代码生成器能直接从表生成全套骨架,这是我最常用的提速手段。

// MyBatis-Plus 代码生成器核心配置 FastAutoGenerator.create( "jdbc:mysql://localhost:3306/medicine_erp?serverTimezone=Asia/Shanghai", "root", "你的密码") .globalConfig(builder -> builder .author("you") .outputDir(System.getProperty("user.dir") + "/src/main/java") .disableOpenDir()) .packageConfig(builder -> builder .parent("com.example.erp") .entity("entity") .mapper("mapper") .service("service") .controller("controller")) .strategyConfig(builder -> builder .addInclude("customer_qualification", "cold_chain_log") // 只生成指定表 .entityBuilder() .enableLombok() .logicDeleteColumnName("deleted") // 逻辑删除字段 .controllerBuilder() .enableRestStyle()) .execute();

addInclude指定要生成的表,避免把系统表也生成一遍。logicDeleteColumnName对应你表里的逻辑删除字段,生成后删除操作会自动变成 update。enableRestStyle生成@RestController风格的接口。生成完不要直接用,entity 上的字段注释、校验注解、以及医药特有的业务校验(比如批准文号格式)还得自己补,生成器只解决重复劳动,不解决业务正确性。

我自己的习惯是:拿到任何一套进销存源码,先花半小时核对库存表的唯一约束和出库明细有没有batch_id,这两个点决定了这套代码能不能真正用于医药场景。如果这两处缺失,别急着在上面堆功能,先把批号维度补齐,否则后面每加一个模块都要还债。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询