ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Java+Spring Boot的医院药品管理系统设计与实现

基于Java+Spring Boot的医院药品管理系统设计与实现 接手医院药品管理系统这个项目的时候我正在医院信息科做运维开发。排在我前面的需求单子上至少躺着四十条关于药房药品账实不符、效期预警形同虚设、手工盘库耗时耗力的抱怨。市面上的ERP和HIS药品模块要么贵到离谱要么逻辑僵化很难适配药库、门诊药房和住院药房三套完全不同的作业方式。最后我们决定基于Java Spring Boot从零设计一套医院药品管理系统。这篇博文完整复盘这套系统的设计与实现从业务边界、技术选型、数据库建模到核心功能、上线踩坑把整条链路讲清楚给正在做药品管理、进销存或类似毕业设计的Java开发者一份可复现的参考。1. 医院药品管理的业务地图药库、门诊药房和住院药房如何协同1.1 三个作业中心的职责划分医院药品管理不能简单理解成“进销存”它背后有三个截然不同的作业中心每个中心的作业节奏和管理粒度都不一样。药库负责所有药品的采购入库、供应商送货、验收入库以及向各药房调拨出库。药库里药品以整箱、整件为主管理单位是“最小销售单位”但采购单上经常出现“盒”“箱”这类大包装所以入库环节必须有包装单位换算逻辑。门诊药房面对的是门诊处方单张处方品种少、数量小、节奏非常快药师需要按处方快速定位药品、核对批号、完成发药还要处理退药。住院药房则按病区医嘱发药一张住院患者用药汇总单可能涉及几十条明细药品消耗量比门诊大得多更关注按病区和时间段的库存盘点。如果只做一套简单的CRUD同时服务这三类角色结果往往是药库觉得不顺手门诊觉得太慢住院觉得盘点对不上。我一开始就把三者的共性和差异梳理出来分别体现在菜单、表结构和权限设计上而不是想用一个万能的“商品管理”页面覆盖所有场景。1.2 核心业务单据入库单、调拨单与处方单药品管理系统最重要的三张单据是入库单、调拨单和处方单整张数据模型基本围绕这3条主干展开。入库单的流转药库根据库存下限生成采购申请采购员下单给供应商供应商送货库管验收入库时登记批号、生产日期、有效期系统生成批次库存再按药房需求开调拨单出库。门诊药房和住院药房接收调拨单后生成自己的库存记录。处方单则以HIS系统传来的处方或药师手工录入的处方开始经过审核、扣减库存、发药确认最后形成发药记录和退药记录。设计时我特别注意的是每张单据都要有独立的业务状态字段比如草稿、已审核、已入库、已作废不能靠删除操作来反悔。药品涉及用药安全任何反向操作都应该走“红冲”或“退单”流程留下痕迹而不是直接把那条记录从数据库删掉。1.3 第一版功能清单最终敲定的第一版功能清单如下这也是我认为一家医院药品管理系统最基本的能力底盘模块核心功能说明基础数据药品字典、供应商、厂家、科室、用户药品字典包含通用名、商品名、规格、剂型、生产厂家、批准文号库存管理入库、调拨、出库、盘点、拆零按批次管理库存强制执行先入先出处方管理处方录入、审核、发药、退药对接HIS处方接口支持打印用药标签预警管理近效期预警、库存下限预警、零库存预警定时任务扫描站内信和企业微信推送报表统计库存台账、进销存报表、科室消耗支撑月底盘点和药事审计系统管理用户、角色、权限、操作日志数据权限按药库、门诊、住院隔离这个表看起来不复杂但每一行在后面都对应着一批表和接口。比如“库存管理”里的拆零如果不做后面的批次效期管理就会遇到“半片药怎么记账”的问题。2. 技术选型与系统架构Spring Boot 3 Java 17 MySQL 8 的组合逻辑2.1 为什么选Java Spring Boot而不是Python或Node.js选型时团队内部也争论过要不要上Python的Django或者Node.js的Express最后坚持Java Spring Boot的原因有三点。第一医院现有系统基本都是Java技术栈信息科维护过多个SSH和Spring MVC老项目大家最熟悉的语言就是Java引入Python或Node相当于增加一套新的运维成本对一个小团队完全不划算。第二Spring Boot的生态对“管理系统”这类场景几乎是量身定做的。Spring Security处理认证授权MyBatis-Plus处理单表CRUD和分页XXL-Job处理定时任务Redis处理Token和缓存这些组件互相配合得很顺社区资料多遇到问题很容易找到解决方案招人难度也远低于自研框架。第三药品管理系统本质是重事务、重数据一致性的业务系统Java的强类型、Spring声明式事务、成熟连接池这些能力在“谁都不能算错账”的场景里比脚本语言更让人放心。我最终选了Spring Boot 3.2.x Java 17MySQL 8.0 MyBatis-Plus 3.5.x Redis 7。Java 17是LTS版本Spring Boot 3全面基于Jakarta EE规范后续升级有保障。这里提醒一句网上大量旧教程里javax.servlet的写法在Spring Boot 3下已经改成jakarta.servlet搜代码时一定要注意版本匹配否则会复制一堆编译不过的代码。2.2 单体分层 前后端分离的架构设计这个系统我没有拆微服务。医院药品管理业务的并发量并不高真正的难点在数据一致性和流程严谨性单体应用加合理分层就能解决拆微服务反而要处理分布式事务、服务编排这些额外复杂度得不偿失。整体架构分成五层。前端层用Vue 3 Element Plus Axios部署时把dist静态资源打包进Spring Boot的resources/static目录节省Nginx配置成本。接口层使用Spring MVC RESTful接口统一返回Result对象统一异常拦截。业务层放Service接口和实现事务控制在业务层核心单据操作使用声明式事务。数据层用MyBatis-Plus做基础CRUD复杂SQL手写Mapper XML避免全表扫描和N1查询。基础设施层用MySQL存业务数据、Redis存登录Token和验证码、XXL-Job做定时预警任务。关于前后端分离给出一个比较稳妥的组合开发环境用Vue的devServer把API请求代理到Spring Boot的8080端口前端调试和后端调试互不干扰生产环境不需要单独部署Nginx直接把Vue打包后的dist目录复制到Spring Boot项目的src/main/resources/static下打成一个jar。这样一台服务器就能把前端和后端都跑起来非常适合医院内网环境。2.3 Maven工程结构与关键依赖项目用Maven管理按包名划分domain、mapper、service、controller但没有拆成多模块Maven工程。以小团队的体量来说多模块带来的构建复杂度和热部署成本实际上高于收益拆开后反而每改一个模块都要重新install开发体验反而不如单工程清爽。关键依赖清单如下依赖版本用途spring-boot-starter-web3.2.xWeb服务mybatis-plus-boot-starter3.5.xORM、分页mysql-connector-j8.2MySQL驱动spring-boot-starter-data-redis3.2.x缓存、Tokenspring-boot-starter-security3.2.x认证授权hutool-all5.8.xID生成、日期、Excel工具xxl-job-core2.4.x分布式定时任务Controller层统一返回ResultT里面包含code、message、data三个字段。异常用RestControllerAdvice统一处理把ServiceException转成HTTP 200加业务错误码而不是让前端收到一个莫名其妙的500页面。这个约定非常实用前端只需要统一处理Result结构业务错误码单独做提示不用每种异常都单独封装。3. 数据库建模药品批次、效期与库存三张表怎么拆才不出错3.1 药品主数据与药品批次必须拆成两张表建表是这套系统花时间最多的环节。药品管理最容易被外行看轻的地方就是“药品”并不是一个简单的商品。同一种药不同厂家、不同批号进价不同、效期不同、存储位置也可能不同所以“药品主数据表”和“药品批次表”必须拆成两张表。药品主数据表drug_info的核心字段包括id、drug_code药品编码唯一、generic_name通用名、trade_name商品名、spec规格如0.25g*24片/盒、dosage_form剂型、manufacturer生产厂家、approval_number批准文号、unit最小单位片/支/袋、purchase_price和sale_price进价/零售价decimal类型、stock_lower_limit库存下限、status停用/启用。批次表drug_batch的核心字段包括id、drug_id关联drug_info、batch_no生产批号、supplier_id供应商、production_date生产日期、expire_date有效期至、barcode药品电子监管码、status未启用/正常/过期/冻结。两表的联合唯一索引uk_drug_batch(drug_id, batch_no)必须加上防止同一个药品的同一批号被重复录入。有些系统图省事把批号和效期直接挂在药品主表上这是最危险的建模方式。同一批次药品分两批到货效期不同挂在主表就只能被后到的覆盖月底账目对不上是必然的。真正的库存维度应该是drug_id batch_id 库房。3.2 实时库存与流水账分离的模型库存不要只存一个总数字应该拆成“当前库存表”和“库存流水表”。当前库存表drug_stock保存药品在某库房的批号实时数量字段包括id、drug_id、batch_id、warehouse_id、quantity、last_in_time、last_out_time。这里必须加唯一索引uk_stock_wh(drug_id, batch_id, warehouse_id)保证同一药品同一批次在同一库房只有一条记录。有了这个唯一键入库时可以直接用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity ?的写法把“查询是否存在”和“累加库存”合并成一个数据库原子操作从根上避免并发情况下先查后改导致数据不一致。程序里那种先select再update的写法在并发窗口下很容易把库存写错。库存流水表stock_flow记录每一次库存变动字段包括id、stock_id、drug_id、batch_id、flow_type1入库、2调拨、3处方发药、4退药、5盘点、6报废、change_quantity正数增加负数减少、before_quantity和after_quantity变动前后数量、biz_no业务单据号、operate_user操作人、create_time。流水表的作用是让每一笔账面变化都有依据任何一次扣减都能追溯到是哪张单据、哪个操作人、变动前是多少、变动后是多少。没有流水表的进销存系统数据错了根本没法治这也是医院审计和月底盘点的底线。3.3 处方主表和明细表的状态机设计处方表由主表和明细表组成。处方主表prescription保存处方号、患者类型门诊/住院、患者姓名、年龄、科室、开方医生、审方药师、发药药师、状态、创建时间。处方明细表prescription_item保存药品ID、批次ID、数量、单价、小计、发药状态。处方明细里的batch_id在发药时才写入而不是开方时写死。医生开方只指定药品药师发药时才决定具体发哪个批次这是药品调剂业务的惯例也是先入先出策略落到代码里的前提。如果开方时就把批次定死会出现效期近的批次永远发不出去的尴尬情况。处方状态机要仔细设计。我把状态定义为0待提交、1待审核、2已审核待发药、3已发药、4部分退药、5已退药、6已作废。Service里用一个状态流转校验方法统一处理禁止任何两个状态之间乱跳。比如已作废的处方不能直接改回已发药必须走反向作废流程。状态字段用tinyint存数字前端展示层再做文字映射不要直接存中文状态否则后续加状态和统计都会很难受。3.4 字段类型、编码与索引设计的几个约定再补几个容易被忽略的字段级约定。金额字段用decimal(10,2)坚决不用float或double药品进价、零售价这种牵扯金额计算的字段浮点误差会带来账目隐患。数量字段用decimal(12,3)因为拆零后会出现0.5、0.2这种小数如果只用int拆零业务完全做不了。批量入库时给的可能是盒数发药扣的是片数单位换算在流水表里必须记录原单位和换算率。所有时间字段用datetime不要用timestamp。虽然两者底层实现不同但datetime在业务系统中可读性更好医院这种基本不考虑跨时区的场景没有理由用timestamp。编码字段如药品编码、处方号用varchar而不是bigint因为这类编码通常是规则串比如YP20240101001直接varchar可以避免类型转换的坑。最后所有外键字段都要建普通索引或联合索引不要只依赖主键索引否则按药品查库存、按批次查流水的查询全部会全表扫描。4. 核心功能落地入库、扣减、预警和处方闭环的实现细节4.1 入库接口批次生成、效期校验与库存原子累加入库接口是整个系统的起点。在DrugInboundService里第一步先按药品和批号查找或创建批次记录第二步把库存累加到drug_stock第三步写流水第四步更新入库单状态。四个步骤要放在同一个事务里。核心逻辑大致是这样Transactional(rollbackFor Exception.class) public void inbound(InboundDTO dto) { DrugBatch batch drugBatchMapper.selectOne( new LambdaQueryWrapperDrugBatch() .eq(DrugBatch::getDrugId, dto.getDrugId()) .eq(DrugBatch::getBatchNo, dto.getBatchNo())); if (batch null) { batch new DrugBatch(); batch.setDrugId(dto.getDrugId()); batch.setBatchNo(dto.getBatchNo()); batch.setProductionDate(dto.getProductionDate()); batch.setExpireDate(dto.getExpireDate()); batch.setSupplierId(dto.getSupplierId()); batch.setStatus(BatchStatus.NORMAL); drugBatchMapper.insert(batch); } int affected drugStockMapper.increaseStock( dto.getDrugId(), batch.getId(), dto.getWarehouseId(), dto.getQuantity()); if (affected 0) { throw new ServiceException(库存写入失败); } stockFlowService.record(dto, FlowType.INBOUND, batch.getId()); inboundOrderService.updateStatus(dto.getOrderId(), OrderStatus.FINISHED); }这里有几个必须注意的细节。入参里的expireDate和productionDate后端必须做业务校验生产日期不能晚于今天有效期不能早于入库日期前端校验只是体验后端校验才是底线。增加库存不要先select再update直接用数据库的原子更新少一次查询也少一个并发窗口。入库单状态更新和库存写入必须在同一个事务里如果只成功写了库存而单据状态没更新第二天回查账目时就会产生无头流水。4.2 出库扣减FOR UPDATE行锁与先入先出FEFO出库扣减是这套系统里最容易出并发问题的地方。两个窗口的药师同时给不同患者发同一批药品时如果程序先查库存再执行quantity quantity - 1就会出现超扣甚至负数库存。我的做法分两步。第一步对drug_stock按drug_id warehouse_id锁定可用的批次行。MyBatis-Plus里写自定义SQLselect idselectStockForUpdate resultTypeDrugStock SELECT s.* FROM drug_stock s JOIN drug_batch b ON s.batch_id b.id WHERE s.drug_id #{drugId} AND s.warehouse_id #{warehouseId} AND s.quantity gt; 0 ORDER BY b.expire_date ASC, s.batch_id ASC FOR UPDATE /select这条SQL有两个作用。一是FOR UPDATE把符合条件的库存行全部锁住让并发事务在行锁上排队从根源上杜绝超卖二是按有效期从近到远排序天然实现先入先出FEFO。如果药品量大、批次多可以把expire_date冗余到drug_stock表并建索引避免排序时关联批次表。第二步拿到锁后逐批扣减Transactional(rollbackFor Exception.class) public void deductStock(PrescriptionItem item) { ListDrugStock stocks drugStockMapper.selectStockForUpdate( item.getDrugId(), item.getWarehouseId()); BigDecimal toDeduct item.getQuantity(); for (DrugStock stock : stocks) { if (toDeduct.compareTo(BigDecimal.ZERO) 0) break; BigDecimal deduct stock.getQuantity().min(toDeduct); drugStockMapper.deductStock(stock.getId(), deduct); stockFlowService.record(stock, deduct.negate(), item.getPrescriptionNo()); toDeduct toDeduct.subtract(deduct); } if (toDeduct.compareTo(BigDecimal.ZERO) 0) { throw new ServiceException(药品库存不足); } }这段代码用BigDecimal.min处理拆零扣减而不是直接相减再判断负数。即使库存是0.3处方需要0.5也能先扣0.3再扣下一个批次的0.2完全符合药房实际作业。如果自己写if (stock.getQuantity() need)再setQuantity遇到拆零基本算不对账。另一个经验是处方明细里的quantity和price在开方时就要锁定并写入明细表发药时不要再查最新零售价。事后对账时金额来自快照而不是实时值才经得起审计。4.3 过期预警和库存下限预警的定时任务设计预警模块依赖定时任务。最朴素的做法是Spring的Scheduled注解每天凌晨扫描一次逻辑很简单Component public class DrugWarnJob { Scheduled(cron 0 0 3 * * ?) public void expireWarn() { ListDrugBatch expiring drugBatchMapper.selectExpiringSoon(90); for (DrugBatch batch : expiring) { DrugInfo drug drugInfoMapper.selectById(batch.getDrugId()); BigDecimal qty drugStockMapper.sumQuantityByBatch(batch.getId()); warnMessageService.push(药品【 drug.getGenericName() 】批号【 batch.getBatchNo() 】将在 batch.getExpireDate() 到期剩余库存 qty); } } }预警周期要做成可配置的我在sys_config表里放三个参数expire_warn_days90、stock_lower_warn_enabledtrue、warn_webhook_url。90天是基础值门诊药房一般看30天内效期更实际住院药房看60天参数不配置化就得改代码重新发版很被动。上线后我建议把定时任务换成XXL-Job。Scheduled只在单机内触发一旦需要部署多个实例任务会重复执行预警消息也会重复推送XXL-Job能靠执行器路由策略避免重复调度还自带执行历史、失败重试和调度时间可视化排障方便得多。架构上预警接口先查询需要预警的批次ID集合再批量推送消息不要边查边推否则一次推送失败会污染整批查询结果。4.4 处方审核到发药确认的闭环药品发出前必须经过两道人工确认审方药师审核处方调剂药师确认发药。HIS系统传入处方后进入待审核状态审方药师确认药名、剂量、相互作用。系统同时做基础校验药品是否停用、库存是否充足、是否近效期不可发。校验不通过就把处方锁住弹窗提示具体原因。发药确认时药师扫描药品条码前端传batchId和quantity到后端后端依次执行锁定库存、写处方明细的批号、扣库存、写流水、更新处方状态。如果一张处方有多项药品只要任何一项库存不足整个发药事务回滚避免出现“发了部分药、系统扣了部分库存”的脏状态。事务回滚后前端需要重新拉取最新库存并提示库存不足的药品明细让药师决定是否部分发药还是整单作废。5. 上线前踩过的三个坑事务自调用、并发超卖与数据越权5.1 事务自调用失效this.save()为什么没被Spring代理第一版代码里在StockFlowService内部写了这样一个方法public void record(StockFlow flow) { save(flow); // this.save() }然后在入库Service里调用stockFlowService.record(flow)。表面看逻辑没问题但入库测试时我发现入库单状态更新抛异常后流水已经写进库里事务没有整体回滚。查了很久才意识到是事务自调用问题——record()没有经过Spring的代理对象Transactional注解根本没生效。修复方案很简单把record()方法从当前类中拆分出去放到独立的StockFlowRecordService中让外部调用经过Spring代理或者直接注入TransactionTemplate手动控制事务边界。核心教训是Spring声明式事务基于动态代理同类内部方法调用不会走代理注解自然失效。这个坑在几乎所有Spring项目里都存在尤其在Service层内部互相调用时特别容易踩。5.2 并发扣库存从超卖到SELECT FOR UPDATE的排查过程第一次联调时我们做了个并发测试两个线程同时扣同一批次库存100和80初始库存150。预期结果是一个成功、一个失败但实际跑出来库存变成了负数。问题根源非常简单当时用的是“先查后更”DrugStock stock drugStockMapper.selectById(stockId); if (stock.getQuantity().compareTo(need) 0) { stock.setQuantity(stock.getQuantity().subtract(need)); drugStockMapper.updateById(stock); }两个线程同时select到quantity150都通过了库存判断然后各自update最终结果取决于后提交的事务。修复方案就是4.2节写的SELECT ... FOR UPDATE或者使用带条件的原子更新UPDATE drug_stock SET quantity quantity - #{need} WHERE id #{id} AND quantity #{need}一条SQL搞定并发扣减不再需要应用层判断。这里也说明一下为什么没用乐观锁版本号在发药这种高频操作里版本号冲突重试会增加用户等待而且在批量扣减多个批次时乐观锁的复杂度和维护成本明显高于行锁。只要事务时间短、锁粒度精确MySQL行锁完全够用。5.3 权限越权角色相同不等于数据可见范围相同第三期迭代时临床护士反馈门诊药房的账号竟然能查到住院药房的库存调拨单。排查后发现问题出在权限校验只判断了“用户是否属于药房角色”没有校验“数据行是否属于该用户所在的库房”。Spring Security只解决“谁能登录、这个角色能访问哪些URL”解决不了“同角色用户之间数据范围隔离”。门诊药师和住院药师都是ROLE_PHARMACIST仅靠角色判断必然越权。我的修复方案是在MyBatis-Plus查询条件里根据LoginUser.getWarehouseId()动态拼接warehouse_id条件同时禁止在Mapper XML里写selectByWarehouseId(null)这类无差查询。权限模型最后是这样落地的用户表关联科室类型区分药库、门诊药房、住院药房。每个接口从Token解析出的用户上下文里取warehouseId无法提供时默认拒绝访问。核心业务表全部带warehouse_id字段查询条件强制带上。这个经验对多租户系统同样适用数据权限必须在数据访问层强制过滤不能依赖前端传参否则越权只是时间问题。6. 测试方案、Linux部署经验与后续扩展方向6.1 测试分三层接口测试、SQL审查与并发压测系统测试我分了三层做。接口测试用Postman或JMeter先把所有REST接口的URL、请求体、返回体整理成接口文档对入库、发药、退药、盘点这些关键接口分别验证正常流程和异常流程。入库数量为0应该报错处方药品停用应该被拦截库存不足时返回业务错误码而不是500。SQL审查要在开发环境开启MyBatis-Plus的SQL日志输出逐条检查生成的SQL。重点看三点一是有没有全表扫描二是update语句有没有丢失where条件三是复杂查询有没有N1。药品字典和批次的查询频率非常高要配合Redis缓存把重复查询压下来。MyBatis-Plus的updateById是整行更新容易把不需要改的字段一并更新核心单据最好手写update set只更新指定字段。并发压测用JMeter打“发药”这条链路模拟50个线程同时发同一种药品。验收标准是总扣减数不能超过初始库存失败请求必须返回“库存不足”错误码不能出现500和死锁。压测时把数据库锁等待超时时间调到比默认更短可以更快暴露死锁和长时间持锁问题。6.2 生产部署Docker Compose单机方案与静态资源托管部署方案我推荐Linux加Docker Compose不搞K8s。MySQL 8、Redis 7、Spring Boot 3的jar包、XXL-Job调度中心各打成一个容器服务。Spring Boot配置文件里数据库密码用环境变量注入不要写死在jar包里。前端Vue项目构建后把dist目录复制进Spring Boot的resources/static由Spring Boot直接托管静态资源一台服务器就能跑完整套系统。有两个常被忽略的部署细节。第一Spring Boot内嵌Tomcat默认对静态资源的缓存策略可能不够医院内网用户多、带宽有限建议开启压缩server: compression: enabled: true mime-types: text/html,text/css,application/javascript,application/json servlet: encoding: charset: UTF-8 tomcat: max-threads: 200第二Vue使用history路由时直接打包进Spring Boot会导致刷新页面404。因为打包前所有前端路由由VueRouter接管打包后刷新时请求会落到后端后端没有对应路由就返回404。处理方式是配置Spring Boot将/api之外的请求全部转发到index.html或者前端改用HashRouter。HashRouter体验稍差但配置最简单适合管理系统这类对URL美观要求不高的系统。6.3 后续扩展方向的四个思路系统上线稳定之后我规划了几个扩展方向大家可以参考。第一供应商和药品资质管理。把供货资质、药品注册批件、效期证照统一存储证照到期前自动提醒供应链合规性更强。第二对接HIS药品字典。医院HIS里会有自己的药品编码两套系统的药品字典必须做映射。最稳妥的做法是定时任务同步HIS的药品主数据并维护映射表避免两套系统各说各话。字段冲突时以HIS编码为主本系统编码作为内部关联键对外接口统一走HIS编码。第三拆零管理和智能盘点。拆零药品比整盒药复杂得多需要记录拆零时间和操作人盘点时引入扫码枪扫描药品条码自动确认数量盘点效率能提升不少。盘点差异单要单独建表记录原库存、盘后库存、差异数量和操作人差异超过阈值必须走审批。第四引入审批流。采购申请、报损报废、紧急领药这些场景都需要多级审批。不需要上太重的工作流引擎一张审批单表加一张审批记录表就能满足大多数医院场景。审批状态和业务单据状态分离审批通过后回调业务单据进行状态流转。最后再分享一个实际体会药品管理系统真正考验人的不是代码能力而是把模糊业务规则转换成明确状态机和数据约束的能力。批号、效期、库存这三件事拆清楚了系统就成功了一大半事务、并发、权限这三个坑绕过去了系统才能从毕业设计级别进化到能上线使用的级别。希望这篇复盘能让你少走几步弯路。
返回列表