Java医药进销存系统源码详解:从架构到二次开发
2026/9/14 23:48:13 网站建设 项目流程

简介:这是一份基于Java与SQL Server 2014开发的医药进销存管理系统完整源码,面向医药行业中小企业及Java学习者,覆盖基本信息、业务管理、业务查询、用户管理和系统管理五大核心模块,适合作为课程设计或毕业设计参考。资源包共284个文件,压缩后约16.27MB,内含36个Java源文件、137个class编译文件、35个jar依赖库、22个XML配置文件,以及SQL Server数据库备份文件(mdf/ldf/bak)和界面图标(png/ico)等,便于直接导入Eclipse运行或二次开发。系统支持操作员添加、客户/商品/供应商信息维护、药品采购、销售、退货及入库明细查询等完整业务流程,并配有用户管理与系统管理模块,可帮助读者快速理解医药进销存的数据流转和界面实现。目前该资源已有2675人学习下载,适合需要完整可运行源码、数据库脚本及配置文件作为实践参考的开发者。

1. 医药进销存系统到底在管什么:从采购到 GSP 的四个核心闭环

医药行业的进销存系统和普通商贸进销存最大的差别不在“进、销、存”这三个字,而在医药流通环节里那套绕不开的 GSP(药品经营质量管理规范)约束。药品入库要验批次、登记效期,出库要按批号追溯,效期近了要预警,温湿度记录要留痕。这套系统管的是药品在“采购入库 → 库存养护 → 销售出库 → 财务结算”四个闭环里的状态变化,每一步都涉及批号、数量、金额和操作人四个维度。

Java 技术栈在这个领域几乎是标准配置,市面上能见到的医药进销存源码,早些年以 SSH(Spring + Struts2 + Hibernate)为主,近几年新写的项目基本转向 Spring Boot + MyBatis + Vue 前后端分离或 Thymeleaf 服务端渲染。对于准备 Java 面试的开发者和需要做课设、毕设或公司内部小系统的工程师来说,这类源码的价值在于它把“库存表怎么设计”“多表事务怎么控制”“批号追溯怎么做”这些八股文背过的知识点,变成了可以直接跑起来的真实工程。文章后续会以一套典型的 Java 医药进销存系统为蓝本,讲清楚这类系统的表结构、核心业务代码、部署方式和二次开发切入点。

2. 拆开源码包:Java 医药进销存的技术架构与项目结构

拿到一个 Java 医药进销存管理系统源码包,第一步不是急着改代码,而是先花十分钟看清它的技术栈和目录结构。不同的技术选型直接决定数据库连接方式、事务管理策略和部署步骤。

2.1 技术栈选型:SSH、SSM 还是 Spring Boot

一套典型的 Java 医药进销存系统,按技术演进大致分三类。老项目的 SSH 组合(Struts2 + Spring + Hibernate)现在很少新写,但存量代码还有不少在跑,面试时如果被问到“SSH 和 SSM 的区别”,底层逻辑是 Struts2 的拦截器机制对请求的拦截粒度比 Spring MVC 粗糙,Hibernate 的全自动 ORM 映射在复杂报表查询上又不如 MyBatis 的手写 SQL 可控。这几年新交付的医药进销存源码,主流是 Spring Boot 2.x/3.x + MyBatis-Plus + MySQL,前端有的用 Vue 2/3 + Element UI,有的直接用 Thymeleaf 渲染服务端页面。

看一个源码包的技术栈,先看 pom.xml 或 lib 目录。以 Maven 工程为例,关键依赖决定了整个项目的骨架:

<dependencies> <!-- Web 启动器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 与 MySQL 驱动 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok 简化实体类 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

依赖配置里最值得关注的是mysql-connector-j的版本。MySQL 5.7 和 MySQL 8.x 的驱动类名不同,8.x 的驱动类自动注册,URL 里需要显式指定serverTimezone=Asia/Shanghai,否则连接 MySQL 8 时会直接报时区错误。这套配置对应的是标准的 Spring Boot 2.7 写法,如果你拿到的源码是 Spring Boot 3.x,javax.*包名要换成jakarta.*,MyBatis 依赖也要升到mybatis-spring-boot-starter3.x 版本,这是老源码在本地跑不起来的头号原因。

2.2 项目目录结构与源码包内常见分包方式

医药进销存的业务边界清晰,分包方式也相对固定。解压 zip 后,标准 Spring Boot 工程的目录结构大致是:

src/main/java/com/example/pharmacy/ ├── controller/ # 控制器层,接收 HTTP 请求 │ ├── DrugController.java │ ├── PurchaseController.java │ ├── SaleController.java │ └── StockController.java ├── service/ # 业务层,事务边界在这里 │ ├── PurchaseService.java │ ├── SaleService.java │ └── StockService.java ├── mapper/ # MyBatis 数据访问层 │ ├── DrugMapper.java │ └── StockMapper.java ├── entity/ # 数据库实体 └── common/ # 通用返回结果、异常处理 src/main/resources/ ├── mapper/ # MyBatis XML 映射文件 ├── application.yml └── db/init.sql # 建表脚本

摸索这类源码时,优先读三个文件:application.yml(数据源配置)、db/init.sql(建表脚本)和PurchaseService.java(入库业务核心)。从这三个文件能快速提取出系统的数据模型和业务边界。常见的坑是源码包里的 SQL 文件是旧版 MySQL 导出的,表名或字段名用了中文注释但数据库连接没设characterEncoding=utf8,一跑就乱码。这个在部署章节会专门讲。

2.3 数据库表设计:批号、效期与库存流水的关系

药品进销存的核心表不会超过十张,但表与表之间的事务关联比普通商贸系统复杂。典型表设计如下:

表名核心字段职责说明
sys_userid, username, password, role_id操作员与权限,role 区分管理员/库管/收银
drug_info药品编码、名称、规格、生产厂家、批准文号药品主数据,唯一约束是药品编码
supplier供应商名称、GSP 证书编号、联系人采购对象主数据
purchase_order单据号、供应商、采购员、总金额、状态采购单主表,状态区分草稿/已入库
purchase_detail药品、数量、进价、批号、效期采购明细,批号和效期在入库时录入
stock_info药品、批号、库存数量、存放位置实时库存,按“药品+批号”维度存储
sale_order / sale_detail单据号、客户、药品、批号、售价销售出库,批号从库存中选择
stock_log药品、批号、变动类型、变动数量、操作人库存流水,进销存追溯的底表

stock_info表的设计是整个系统的关键,它按“药品编码 + 批号”双维度记录库存,不能说某个药品有 100 盒,而是要说“阿莫西林胶囊 0.25g*24 粒,批号 20240301,还有 40 盒”。这在药品经营中是硬性要求,同一种药不同批号的进价、效期、供应商可能都不同,混在一起就无法实现批号追溯。建表时常见的做法是给drug_codebatch_no建联合索引,查询库存和写流水时都以这两个字段为条件。

CREATE TABLE stock_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_code VARCHAR(32) NOT NULL COMMENT '药品编码', batch_no VARCHAR(64) NOT NULL COMMENT '生产批号', stock_qty INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', expire_date DATE NOT NULL COMMENT '效期', location VARCHAR(64) DEFAULT NULL COMMENT '货位', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_drug_batch (drug_code, batch_no) ) COMMENT '实时库存表';

建表脚本里值得留意的三个细节。第一,stock_qty用了INT而不用DECIMAL,因为药品按最小包装单位计数的场景远多于按重量,除非涉及原药材或散装饮片,否则整数足够。第二,expire_dateDATE类型,效期预警的 SQL 直接用DATEDIFF(expire_date, CURDATE())算天数差即可,不建议存字符串在 Java 层做判断。第三,UNIQUE KEY uk_drug_batch这个唯一约束是防止同药同批号重复入库的兜底,业务层拦截 + 数据库约束双保险。

3. 进销存核心业务实现:入库、出库、盘点与效期预警

数据库表建好后,进销存系统的核心代码围绕三类业务展开:采购入库、销售出库、库存盘点与预警。这三块业务几乎贡献了系统里绝大部分的 Service 层代码和事务边界设计。

3.1 采购入库:批号录入与库存初始化的手写实现

采购入库的业务流程可以概括为:前端提交采购单及明细 → 后台逐条校验药品是否存在、进价是否合理 → 按“药品 + 批号”维度写入实时库存 → 记录采购流水与库存流水 → 更新采购单状态。这个流程涉及多张表的写入,必须用事务保证一致性。

@Service public class PurchaseServiceImpl implements PurchaseService { @Autowired private PurchaseOrderMapper purchaseOrderMapper; @Autowired private PurchaseDetailMapper purchaseDetailMapper; @Autowired private StockInfoMapper stockInfoMapper; @Autowired private StockLogMapper stockLogMapper; @Override @Transactional(rollbackFor = Exception.class) public Long submitPurchase(PurchaseOrderDTO dto) { // 1. 创建采购单主记录,状态置为待入库 PurchaseOrder order = new PurchaseOrder(); order.setSupplierId(dto.getSupplierId()); order.setOperator(dto.getOperator()); order.setTotalAmount(dto.getTotalAmount()); order.setStatus(0); purchaseOrderMapper.insert(order); // 2. 遍历采购明细,逐条入库 for (PurchaseDetailDTO detail : dto.getDetails()) { // 按药品编码 + 批号 查实时库存 StockInfo stock = stockInfoMapper.selectByDrugCodeAndBatch( detail.getDrugCode(), detail.getBatchNo()); if (stock == null) { // 没有该批次的库存记录,新建 stock = new StockInfo(); stock.setDrugCode(detail.getDrugCode()); stock.setBatchNo(detail.getBatchNo()); stock.setStockQty(detail.getQty()); stock.setExpireDate(detail.getExpireDate()); stockInfoMapper.insert(stock); } else { // 已存在,更新数量 stock.setStockQty(stock.getStockQty() + detail.getQty()); stockInfoMapper.updateById(stock); } // 3. 写入库存流水,变动类型为入库 StockLog log = new StockLog(); log.setDrugCode(detail.getDrugCode()); log.setBatchNo(detail.getBatchNo()); log.setChangeType("IN"); log.setChangeQty(detail.getQty()); log.setOperator(dto.getOperator()); stockLogMapper.insert(log); } return order.getId(); } }

这段入库逻辑有一个容易被忽略的边界:如果同一个采购单里出现两行相同“药品 + 批号”的明细,循环第二次时会因为第一次已经插入过stock_info而查到旧记录,逻辑上没有问题,但如果你想在数据库层做兜底,上面表结构里的uk_drug_batch唯一索引会直接抛DuplicateKeyException。所以入参里应该在 controller 层先把重复的药品批号合并成一条,或者明确要求前端不得重复提交。

事务明细表purchase_detail的写入逻辑在代码里被简化了,实际项目中还需要在遍历时逐条插入。这里有个性能上的考量:入库明细如果超过几十条,逐条 insert 会明显变慢,常见的做法是改成 MyBatis 的foreach批量插入,或者用IService接口自带的saveBatch

3.2 销售出库:数量校验、实时扣减与事务回滚

销售出库比入库更复杂的地方在于——出库时要先查库存够不够,扣减时还要防止并发把库存扣成负数。给不同客户发药时,同一个批号的库存可能被多个线程同时扣减,常规的“先查再改”在无锁状态下会出现超卖。

@Override @Transactional(rollbackFor = Exception.class) public Long submitSale(SaleOrderDTO dto) { // 1. 创建销售单,状态为待出库 SaleOrder order = new SaleOrder(); order.setCustomerId(dto.getCustomerId()); order.setOperator(dto.getOperator()); order.setStatus(0); saleOrderMapper.insert(order); for (SaleDetailDTO detail : dto.getDetails()) { // 2. 扣减库存,使用 UPDATE 语句保证原子性 int rows = stockInfoMapper.deductStock( detail.getDrugCode(), detail.getBatchNo(), detail.getQty()); if (rows == 0) { throw new RuntimeException("库存不足或批号不存在: " + detail.getDrugCode() + " / " + detail.getBatchNo()); } // 3. 写销售明细和出库流水 saleDetailMapper.insert(detail); StockLog log = new StockLog(); log.setDrugCode(detail.getDrugCode()); log.setBatchNo(detail.getBatchNo()); log.setChangeType("OUT"); log.setChangeQty(-detail.getQty()); log.setOperator(dto.getOperator()); stockLogMapper.insert(log); } return order.getId(); }

注意第 2 步的deductStock,它不是简单的selectupdate,而是直接在 Mapper 层执行一条带条件的原子 UPDATE:

UPDATE stock_info SET stock_qty = stock_qty - #{qty} WHERE drug_code = #{drugCode} AND batch_no = #{batchNo} AND stock_qty >= #{qty}

stock_qty >= #{qty}这个条件利用了数据库的行级锁,同一时刻只有一个事务能更新这条记录。UPDATE影响行数为 0 时有两种可能,要么批号不存在,要么库存不足。业务上要求把这两种情况区分开以便给客户更准确的提示,就得在 UPDATE 之前先做一次查询判断,或者在deductStock里改成先查再更新的写法并加FOR UPDATE行锁。前者性能更好但要多一次查询,后者锁的粒度更可控但需要把事务隔离级别调到读已提交以上才能生效。

出库逻辑里还要处理“效期拦截”的常识性问题:距效期不足一定天数(比如 30 天)的批次在销售时应该被限制。实现上可以在销售明细插入之前查一下批次效期做一次 if 判断,但更稳的做法是把效期限制也写进 SQL 条件,逼着开发在写库存查询时把效期字段带出来。

3.3 效期预警与库存盘点:SQL 查询和报表实现

效期预警是医药进销存区别于普通商超系统的核心功能。实现思路很简单:查询距今效期不足 90 天的批次,按剩余天数排序输出,每天定时跑一次并推送给库管。SQL 可以做得很直接:

SELECT drug_code, batch_no, stock_qty, expire_date, DATEDIFF(expire_date, CURDATE()) AS remain_days FROM stock_info WHERE expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND stock_qty > 0 ORDER BY remain_days ASC;

DATEDIFF计算出的remain_days可以在 Java 层做分级颜色标记,比如少于 30 天标红,30 到 60 天标黄。报表模块通常还会按近效期药品、滞销药品、库存周转率做几个汇总页,这些页面在后端都是类似的聚合 SQL,区别在于时间维度(按日/按月)和分组维度(按药品/按供应商)。注意这里expire_date走索引的效率不高,因为字段上套了DATEDIFF函数会导致索引失效,数据量大时可以把剩余天数作为冗余字段存进表里,每天用定时任务刷新一次。

盘点业务相对独立,但和进销存的主链路耦合密切。常见的做法是新增盘点单,盘点时把账面库存冻结,然后录入实盘数量,差异生成盘盈盘亏单,由库管确认后走库存调整流水。

4. 本地部署与运行:JDK 配置、MySQL 初始化与启动排错

源码拿到了,代码也看懂了,能不能跑起来是另一回事。部署一个 Java 医药进销存系统的阻力往往不在代码本身,而在环境变量的配置、数据库版本差异和端口占用这些问题上。

4.1 环境准备:JDK 与 MySQL 的版本匹配

先确认源码的编译目标。查看 pom.xml 里的java.version属性,常见的是 1.8 或 11。JDK 装好后要配环境变量,Windows 下在系统变量里新增JAVA_HOME指向 JDK 安装目录,然后编辑Path追加%JAVA_HOME%\bin。Linux/macOS 则在~/.bashrc~/.zshrc里加一行:

export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 export PATH=$JAVA_HOME/bin:$PATH

配置完成后在终端敲java -version验证,输出的版本号必须与 pom 里设置的目标一致或高于目标版本。MySQL 建议直接装 8.x,因为 5.7 已过扩展维护期,且 8.x 对 JSON 支持和窗口函数的兼容性更好。注意mysql-connector-j8.x 驱动要求 MySQL 服务端启用mysql_native_passwordcaching_sha2_password,默认就是后者,不用额外处理。

4.2 修改配置并初始化数据库

src/main/resources/application.yml里改数据源配置,模板如下:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy_db? useUnicode=true&characterEncoding=utf8& serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.pharmacy.entity

serverTimezonecharacterEncoding=utf8这两个参数必须带上,前者解决时区报错,后者解决中文乱码。初始化数据库时用 MySQL 命令行或 Navicat 执行db/init.sql,注意如果 SQL 文件里有DROP DATABASECREATE DATABASE语句,执行前确认当前连的实例是本地开发库而不是线上库。

4.3 常见启动报错与排查顺序

启动时最容易遇到的几个报错和对应解法,按出现概率排序:

报错现象可能原因处理方式
Access denied for user 'root'@'localhost'密码或用户名不对检查 yml 配置,确认 MySQL 用户权限
Unknown database 'pharmacy_db'未创建数据库执行建库语句CREATE DATABASE pharmacy_db DEFAULT CHARSET utf8mb4;
Public Key Retrieval is not allowed8.x 驱动的安全策略URL 里追加allowPublicKeyRetrieval=true
Field 'id' doesn't have a default value主键没有自增检查建表 SQL,id字段必须为AUTO_INCREMENT
Address already in use: JVM_Bind8080 端口被占换端口:java -jar xxx.jar --server.port=8081

排查顺序建议从外到内:先看日志里有没有 SQL 异常,如果有 SQL 异常先拷出 SQL 语句在 Navicat 里单独执行,能确认是 SQL 本身的问题还是数据问题;没有 SQL 异常再看依赖冲突,最常见的是 MyBatis 版本和 Spring Boot 版本不兼容。

如果源码带前端页面且前端是一个独立的 Vue 项目,还需要在vue.config.js里配代理转发到后端端口。

5. 在源码上做二次开发:用 AOP 给进销存系统加操作审计日志

源码跑通之后,最值得做的一件事不是改业务功能,而是给系统加一个操作审计日志。进销存系统的每一次入库、出库、盘点、改价都是要追责的,库管录错了单发现不了是谁干的是大忌。作为二次开发的练手场景,可以用 Spring AOP 以切面方式统一记录“谁在什么时间做了什么操作”,不改已有业务代码。

5.1 定义切面与注解

先定义一个@OpLog注解,加在需要记录的方法上:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OpLog { String action() default ""; }

然后在切面里用环绕通知拦截。Spring AOP 基于动态代理,对 JVM 默认的final方法不生效,所以被拦截的方法不能是 private 或 final,这也是 Java 八股文里反复考到的一个点。

@Aspect @Component public class OpLogAspect { @Around("@annotation(opLog)") public Object record(ProceedingJoinPoint joinPoint, OpLog opLog) throws Throwable { long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); // 保存操作日志:方法名、参数、执行人、耗时 saveLog(opLog.action(), joinPoint.getArgs(), "SUCCESS", System.currentTimeMillis() - start); return result; } catch (Exception e) { saveLog(opLog.action(), joinPoint.getArgs(), "FAIL", 0L); throw e; } } private void saveLog(String action, Object[] args, String status, long cost) { // 这里从 SecurityContext 或当前请求中取操作人 // 组装日志对象后写入 sys_op_log 表 } }

给入参加日志要留意序列化问题。joinPoint.getArgs()里如果含有HttpServletRequestMultipartFile这类不可序列化对象,直接转 JSON 会报错,需要先做类型过滤再记录。审计日志的落库维度建议是:操作人、操作时间、操作类型、业务单号、参数摘要、执行结果、耗时。这个表也可以直接复用现有sys_op_log表,如果源码里没有这张表,按 2.3 小节列出的表风格补一张即可。

5.2 验证与常见误区

PurchaseServiceImplSaleServiceImpl的提交方法加上@OpLog(action = "采购入库")@OpLog(action = "销售出库")注解,重启服务后从前端操作一笔入库单,再查审计表。验证的标准是——数据能查到、操作人准确、参数摘要能还原出当时的单据号。如果审计日志只记了“操作成功”而没有参数,等于白做;如果操作人取不到,检查日志框架里是否注入了RequestContextHolder而不是直接 new 一个空用户。

一个容易踩的坑是调用同类内部方法时 AOP 不生效。如果你把submitPurchase和另一个带注解的方法写在同一个类里,后者被前者通过this.xxx()调用,代理对象不会拦截这次调用。要让切面生效,必须从外部注入的代理对象调用,也就是把被拦截方法放到独立的 Service 里,或者通过ApplicationContext.getBean()获取代理后调用。判断是否生效最直接的方式是在切面里加一行打印,启动时观察日志输出。

药品经营场景下,审计日志的数据保留周期通常不低于 5 年,这个要求不属于性能优化问题,而是数据量规划问题。sys_op_log表要按月份做分区或者定期归档,不能等线上跑了一两年后,一张大表拖垮所有查询。二次开发做完后,建一个索引覆盖operate_timeoperator两个字段,日常查“某个人某天做了什么操作”就可以走索引快速定位了。

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

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

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

立即咨询