1. 项目概述
作为一名在医院信息化建设领域工作多年的开发者,我最近完成了一个基于Spring Boot的医院药品库存管理系统。这个项目源于我在实际工作中遇到的痛点:许多医院仍在使用Excel表格或简单的进销存软件管理药品,经常出现库存不准确、药品过期、采购不及时等问题。
这个系统采用B/S架构,前端使用Vue.js,后端基于Spring Boot+MyBatis Plus,数据库选用MySQL。系统实现了药品全生命周期管理,包括采购、入库、出库、调剂、退货等核心业务流程,同时提供了智能预警和数据分析功能。
2. 技术选型与架构设计
2.1 为什么选择Spring Boot
Spring Boot是我们技术栈的核心选择,主要基于以下几个考虑:
-
快速开发:医院的药品管理系统需求变化频繁,Spring Boot的自动配置和起步依赖能极大提升开发效率。比如通过spring-boot-starter-data-jpa可以快速集成JPA,通过spring-boot-starter-security轻松实现权限控制。
-
微服务友好:考虑到未来可能需要对系统进行扩展(比如对接HIS系统),Spring Boot天然的微服务支持特性让我们可以平滑过渡。
-
丰富的生态系统:Spring生态中有大量现成的解决方案,比如Spring Batch可以用于处理大批量的药品数据导入导出,Spring Cache可以优化高频访问的药品库存数据。
2.2 数据库设计要点
药品管理系统的数据库设计有几个关键点需要特别注意:
- 药品主表设计:
sql复制CREATE TABLE `drug` (
`id` bigint NOT NULL AUTO_INCREMENT,
`code` varchar(32) NOT NULL COMMENT '药品编码',
`name` varchar(100) NOT NULL COMMENT '药品名称',
`spec` varchar(100) NOT NULL COMMENT '规格',
`unit` varchar(20) NOT NULL COMMENT '单位',
`type_id` int NOT NULL COMMENT '药品分类',
`manufacturer` varchar(200) DEFAULT NULL COMMENT '生产厂家',
`approval_number` varchar(100) DEFAULT NULL COMMENT '批准文号',
`barcode` varchar(50) DEFAULT NULL COMMENT '条形码',
`retail_price` decimal(10,2) DEFAULT NULL COMMENT '零售价',
`purchase_price` decimal(10,2) DEFAULT NULL COMMENT '采购价',
`min_stock` int DEFAULT NULL COMMENT '最低库存',
`max_stock` int DEFAULT NULL COMMENT '最高库存',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(1-启用 0-停用)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_type` (`type_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品信息表';
- 库存表设计:
sql复制CREATE TABLE `drug_stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`drug_id` bigint NOT NULL COMMENT '药品ID',
`batch_no` varchar(50) NOT NULL COMMENT '批次号',
`quantity` int NOT NULL COMMENT '数量',
`production_date` date NOT NULL COMMENT '生产日期',
`expiry_date` date NOT NULL COMMENT '有效期至',
`location_id` int NOT NULL COMMENT '库位ID',
`supplier_id` int DEFAULT NULL COMMENT '供应商ID',
`inbound_time` datetime NOT NULL COMMENT '入库时间',
`operator` varchar(50) NOT NULL COMMENT '操作人',
PRIMARY KEY (`id`),
KEY `idx_drug` (`drug_id`),
KEY `idx_expiry` (`expiry_date`),
KEY `idx_location` (`location_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品库存表';
特别注意:药品库存必须按批次管理,这是GMP规范的要求。我们设计了batch_no字段记录批次号,同时记录production_date和expiry_date,这对后期的近效期预警和先进先出管理至关重要。
3. 核心功能实现
3.1 药品入库流程
药品入库是系统最核心的流程之一,我们实现了完整的入库业务链:
- 采购订单对接:系统支持从采购订单直接生成入库单,减少重复录入
- 多维度校验:
- 药品基本信息校验(名称、规格、批准文号等)
- 批次信息校验(生产日期不能晚于当前日期,有效期必须大于6个月)
- 数量校验(实际到货数量与订单数量的合理差异范围)
- 库存更新:采用乐观锁解决并发问题
入库核心代码示例:
java复制@Transactional
public Result inbound(InboundDTO dto) {
// 1. 校验入库单基本信息
validateInbound(dto);
// 2. 锁定采购订单
PurchaseOrder order = purchaseOrderService.lockOrder(dto.getOrderId());
// 3. 处理每个药品项
for (InboundItemDTO item : dto.getItems()) {
Drug drug = drugService.getById(item.getDrugId());
// 校验药品状态
if (drug.getStatus() != DrugStatus.ENABLED) {
throw new BusinessException("药品"+drug.getName()+"已停用");
}
// 创建库存记录
DrugStock stock = new DrugStock();
BeanUtils.copyProperties(item, stock);
stock.setDrugId(drug.getId());
stock.setInboundTime(new Date());
stock.setOperator(dto.getOperator());
// 批次管理校验
if (stock.getExpiryDate().before(DateUtils.addMonths(new Date(), 6))) {
throw new BusinessException("药品"+drug.getName()+"有效期不足6个月");
}
drugStockMapper.insert(stock);
// 更新药品总库存
drugService.updateStock(drug.getId(), item.getQuantity());
}
// 4. 更新订单状态
order.setStatus(OrderStatus.INBOUNDED);
purchaseOrderService.updateById(order);
return Result.success();
}
3.2 库存预警机制
系统实现了多层次的库存预警:
- 库存量预警:
- 当库存低于最小库存时触发黄色预警
- 当库存为零时触发红色预警
- 效期预警:
- 有效期6个月内显示黄色预警
- 有效期3个月内显示红色预警
- 呆滞药品预警:超过6个月未出库的药品预警
预警采用定时任务+事件驱动两种方式:
java复制@Scheduled(cron = "0 0 8 * * ?") // 每天上午8点执行
public void checkStockWarning() {
// 1. 检查库存量
List<Drug> lowStockDrugs = drugMapper.selectLowStockDrugs();
lowStockDrugs.forEach(drug -> {
String message = String.format("药品[%s]库存不足,当前库存%d,最低库存%d",
drug.getName(), drug.getStock(), drug.getMinStock());
warningService.sendWarning(drug.getId(), WarningType.LOW_STOCK, message);
});
// 2. 检查近效期
Date sixMonthLater = DateUtils.addMonths(new Date(), 6);
List<DrugStock> expiringStocks = drugStockMapper.selectExpiringStocks(sixMonthLater);
expiringStocks.forEach(stock -> {
Drug drug = drugService.getById(stock.getDrugId());
String message = String.format("药品[%s]批次%s将在%s过期",
drug.getName(), stock.getBatchNo(),
DateFormatUtils.format(stock.getExpiryDate(), "yyyy-MM-dd"));
warningService.sendWarning(stock.getDrugId(), WarningType.EXPIRING, message);
});
}
4. 系统特色功能
4.1 智能采购建议
系统通过分析历史出库数据,自动生成采购建议:
- 基于过去3个月的出库平均值,考虑季节因素
- 结合当前库存和在途订单
- 考虑药品的采购周期(本地供应商1-3天,外地供应商7-15天)
采购建议算法核心逻辑:
java复制public List<PurchaseRecommendation> generateRecommendations() {
List<Drug> drugs = drugMapper.selectAllEnabled();
return drugs.stream().map(drug -> {
PurchaseRecommendation rec = new PurchaseRecommendation();
rec.setDrugId(drug.getId());
rec.setDrugName(drug.getName());
// 计算日均消耗量
double avgDailyUsage = statisticService.getAvgDailyUsage(drug.getId(), 90);
// 计算安全库存
int leadTime = getSupplierLeadTime(drug.getMainSupplierId());
int safetyStock = (int) Math.ceil(avgDailyUsage * leadTime * 1.2);
// 计算建议采购量
int currentStock = drug.getStock();
int onWayQuantity = purchaseOrderService.getOnWayQuantity(drug.getId());
int recommendQty = safetyStock - (currentStock + onWayQuantity);
if (recommendQty > 0) {
rec.setRecommendQuantity(recommendQty);
// 考虑包装规格
rec.setActualQuantity(adjustByPackageSpec(drug, recommendQty));
rec.setUrgency(getUrgencyLevel(currentStock, safetyStock));
return rec;
}
return null;
}).filter(Objects::nonNull).collect(Collectors.toList());
}
4.2 药品追溯功能
为满足GSP要求,系统实现了完整的药品追溯链条:
- 正向追溯:通过药品批次号可以查询所有相关出库记录、使用患者
- 反向追溯:通过患者可以查询使用的所有药品批次信息
追溯查询示例:
sql复制-- 查询某批次药品的流向
SELECT d.name, d.spec, ds.batch_no,
o.outbound_time, o.quantity,
p.name as patient_name, p.medical_record_no
FROM drug_stock ds
JOIN drug d ON ds.drug_id = d.id
JOIN outbound_detail od ON od.stock_id = ds.id
JOIN outbound_order o ON o.id = od.outbound_id
JOIN prescription p ON p.id = o.prescription_id
WHERE ds.batch_no = '20230501A'
ORDER BY o.outbound_time DESC;
-- 查询某患者使用的所有药品批次
SELECT d.name, d.spec, ds.batch_no, ds.production_date, ds.expiry_date,
o.outbound_time, od.quantity
FROM patient p
JOIN prescription pr ON pr.patient_id = p.id
JOIN outbound_order o ON o.prescription_id = pr.id
JOIN outbound_detail od ON od.outbound_id = o.id
JOIN drug_stock ds ON ds.id = od.stock_id
JOIN drug d ON d.id = ds.drug_id
WHERE p.medical_record_no = 'MR202300123'
ORDER BY o.outbound_time DESC;
5. 部署与性能优化
5.1 系统部署架构
我们采用分层部署架构:
- 前端:Nginx部署Vue静态资源,开启gzip压缩
- 后端:Spring Boot应用部署在Tomcat集群,通过Nginx负载均衡
- 数据库:MySQL主从架构,读写分离
- 缓存:Redis集群,缓存药品基础信息和热点库存数据
- 文件存储:MinIO集群存储药品图片和文档
5.2 性能优化实践
- 库存查询优化:
java复制@Cacheable(value = "drugStock", key = "#drugId")
public DrugStockVO getDrugStock(Long drugId) {
// 原始查询需要关联多张表
return drugMapper.selectStockDetail(drugId);
}
// 使用@CachePut在库存变更时更新缓存
@CachePut(value = "drugStock", key = "#drugId")
public DrugStockVO updateStock(Long drugId, int quantity) {
// 更新数据库
drugMapper.updateStock(drugId, quantity);
// 返回最新数据
return drugMapper.selectStockDetail(drugId);
}
- 批量操作优化:
java复制// 使用MyBatis Plus的批量插入
public void batchInbound(List<InboundItemDTO> items) {
List<DrugStock> stocks = items.stream().map(item -> {
DrugStock stock = new DrugStock();
BeanUtils.copyProperties(item, stock);
return stock;
}).collect(Collectors.toList());
// 使用批量插入
SqlHelper.executeBatch(DrugStock.class,
log,
stocks,
stocks.size(),
(sqlSession, entity) -> sqlSession.insert("com.xxx.DrugStockMapper.insert", entity));
// 使用GROUP BY减少库存更新次数
Map<Long, Integer> drugQuantityMap = items.stream()
.collect(Collectors.groupingBy(InboundItemDTO::getDrugId,
Collectors.summingInt(InboundItemDTO::getQuantity)));
drugQuantityMap.forEach((drugId, qty) -> {
drugMapper.updateStock(drugId, qty);
});
}
6. 踩坑经验分享
6.1 并发更新问题
在初期版本中,我们遇到了库存扣减的并发问题。例如当多个处方同时开具同一种药品时,可能导致库存超卖。
解决方案:
- 使用乐观锁:
java复制public boolean reduceStock(Long drugId, int quantity, Long stockId) {
// 版本号校验
int affected = drugStockMapper.reduceStockWithVersion(
stockId, quantity, System.currentTimeMillis());
return affected > 0;
}
- 引入分布式锁:
java复制public boolean reduceStockWithLock(Long drugId, int quantity) {
String lockKey = "drug_stock_" + drugId;
try {
// 尝试获取锁,等待3秒,锁有效期30秒
boolean locked = redisLock.tryLock(lockKey, 3, 30, TimeUnit.SECONDS);
if (locked) {
Drug drug = drugMapper.selectById(drugId);
if (drug.getStock() >= quantity) {
drugMapper.updateStock(drugId, -quantity);
return true;
}
}
return false;
} finally {
redisLock.unlock(lockKey);
}
}
6.2 药品编码管理
最初我们让医院自行录入药品编码,导致同一药品在不同科室有不同编码,造成管理混乱。
解决方案:
- 建立统一的药品编码规则:
- 前2位:药品大类(01-化学药,02-中成药...)
- 中间4位:药品小类
- 后4位:顺序号
- 最后1位:校验位
- 开发药品编码申请流程,新增药品需经药学部审核
- 实现药品编码自动生成功能
7. 项目总结
这个药品管理系统已在3家医院上线运行,平均减少了药品管理人员30%的工作量,药品库存准确率从原来的85%提升到99.5%,药品过期率降低了60%。系统获得了医院药学部的高度评价。
在实际开发中,我深刻体会到医院信息化系统的几个关键点:
- 业务流程必须合规:药品管理涉及GSP/GMP规范,每个操作都要有完整记录
- 数据准确性至关重要:药品库存数据直接关系到临床用药安全
- 用户体验要兼顾效率与安全:既要简化操作流程,又要防止误操作
未来我们计划增加智能预测功能,基于机器学习算法预测药品需求,进一步提升医院药品管理的智能化水平。