news 2026/10/2 13:08:05

基于Spring Boot的医疗耗材管理系统设计与开发实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot的医疗耗材管理系统设计与开发实践

1. 为什么这个系统不能套用普通进销存

做医疗耗材信息管理系统,第一步不是建表、不是写接口,而是把医院的耗材管理业务理清楚。我第一次做这个项目时就是吃了这个亏,直接按普通仓库系统的思路去建库存表,入库加数量、出库减数量,表面上数据都对得上,结果拿去实际使用时被库房老师问了一句“这批耗材效期还有多久”,整个系统答不上来。医疗耗材和普通百货最大的区别就在于,耗材一旦入库就被绑定了生产批号、灭菌批号和有效期,同一个品名下面可能同时存在好几个批次,各批次的剩余数量和到期日又都不一样。只管理总量、不管理批次的系统,在医院场景里基本就等于废的。

而且这个系统的使用者从来不是单一角色。采购员要处理供应商资质和入库信息,库房管理员要关心库存、效期、批次规则,临床科室护士要提交领用申请,手术室相关的高值耗材还要追溯到患者,财务需要按供应商和科室对账。如果项目的需求调研阶段没有把这些角色和流程走一遍,后期返工的成本会非常难看。医疗耗材系统难就难在它不是简单CRUD,而是要把“差异化的管理粒度”和“相对严格的状态流转”落实到每一个接口设计里。

1.1 高值耗材与低值耗材的管理粒度不同

医疗耗材实际管理时,一般会按风险和价值分成两类来对待。

低值耗材像纱布、注射器、手套,特点是量大、单价低、存放压力大,仓库管理能做到“按批号、按箱规”就已经不错了。出库时往往是整包或者拆零发放,只要能记录消耗数量和对应批次,就已经满足绝大多数场景。这类耗材的核心矛盾是效期,如何防止大批量积压后过期,是库房最关心的问题。

高值耗材像导管、支架、介入类手术耗材,单价高、监管要求也严格。管理上必须精确到单支或者单件,入库时记录注册证号、生产批号、灭菌批号,出库使用后还要关联到科室、手术和患者,万一产品出现质量问题或者召回事件,系统要能按批次一路追回去。

我的建议是在耗材字典上增加一个分类字段,用01-低值、02-高值、03-检验试剂这样的枚举区分。这个字段不只是用来展示的,要在入库、出库、盘点和报表统计里都起到分流作用。高值耗材的模块里我还额外加了一张使用登记表,记录“发给哪个科室”“用于哪台手术”“登记人是谁”“取用时间是什么时候”,这四件事看起来简单,真做到不漏一条,对业务流程设计要求并不低。

1.2 请领、发放、签收的业务闭环

我把整段需求梳理完之后,发现功能虽然多,其实都绕不开一条主链路:科室提交请领单,库房审核库存,审核通过后从批次库存按一定规则出库,科室最后签收确认。流程中随时可能出现特殊情况:科室请领数量大而库存不足,库房只能部分出库;一张请领单涉及多个批次的耗材,系统需要自动按效期优先拆分;效期临近的批次需要先发出去,避免积压过期。

这些业务规则都要固化到系统里,不能依靠科室和库房口口相传。比如我做的出库批次选择策略,就是按“近效期先出”的规则自动匹配批次,而不是由管理员自己找,找到哪个算哪个。高值耗材还要增加“科室二级库”的概念,中心库房把耗材发给科室后,科室收到货不等于已经用完,只有使用登记完成之后才真正从系统里核销。类似这种状态差异,做需求梳理时不问清楚,代码里一定会留下隐患。

所以我的结论是,这个项目能不能做好的关键,不在于页面漂不漂亮,而在于两点:库存模型能否把批次和效期描述到位,出库并发场景下能否按需扣减而不超发。把这两个核心问题想明白,后面写代码的方向就基本不会再跑偏。

2. 技术落位:用Spring Boot + MyBatis搭出可持续迭代的骨架

技术选型我讲一句大实话:别刻意追新。医疗信息系统的真实运行环境经常比大家想象得保守,一台服务器、一个MySQL、一套稳定的Java服务,比堆一堆中间件更符合实际需要。我最终定的组合是Spring Boot做后端框架,MyBatis做持久层,MySQL 8.0做数据库,前端用Vue 3,认证方式用JWT。前后端分离,效率高,也方便后续拆独立服务。

2.1 框架版本:为什么我没有直接上Spring Boot 3

需要强调的一点,是Spring Boot版本选择要慎重。我这次挑的是Spring Boot 2.7.x,JDK用1.8/11都行,网上能搜到的资料也比较完整。我身边有同学直接上Spring Boot 3,必须配JDK 17,MyBatis相关的老依赖在Jakarta命名空间下需要调整,遇到依赖冲突排查起来非常折磨。如果你的项目定位是毕业设计、中小型管理系统、或者希望尽快交付给客户,先跑通业务主干,比顶着最新版本号更有实际价值。

2.2 包结构怎么划分才不会越写越乱

工程结构上,我沿用了最稳妥的三层划分,Controller层、Service层、Mapper层,再加一层entity、dto、vo做对象隔离。按模块分包,而不是按技术分层乱堆,后面新增功能更省心。一个可参考的结构如下:

com.example.hospital ├── controller │ ├── MaterialController.java │ ├── StockController.java │ ├── RequisitionController.java │ └── AuthController.java ├── service │ ├── MaterialService.java │ ├── StockService.java │ └── impl ├── mapper │ ├── MaterialMapper.java │ └── StockMapper.java ├── entity ├── dto ├── vo ├── config │ ├── WebMvcConfig.java │ ├── MyBatisPlusConfig.java │ └── GlobalExceptionHandler.java └── common ├── Result.java └── PageResult.java

这个包结构如果有自己的习惯可以完全调整,但有两个原则不要违反:一是Controller里不要写业务逻辑,只做参数接收和结果返回;二是entity数据库对象和前端展示对象尽量分离,尤其当某个字段不允许直接暴露给前端时,vo层的价值就会体现出来。

2.3 统一响应体、分页与全局异常的约定

项目只要接口一多,统一响应结构就非常重要。我用了比较通用的返回格式:

{ "code": 0, "message": "操作成功", "data": {} }

所有接口都返回这个结构,前端统一拦截code,不为0则弹错误提示。这样做的好处是,后续加全局异常处理时只需要改造一个地方,前端也不会因为不同接口返回结构不同而写一堆兼容代码。分页我直接封装了PageResult,MyBatis-Plus的分页插件配合起来特别顺手。需要提醒的是,分页插件配置要注意数据库方言和版本,8.0的MySQL基本不需要额外处理,但如果用旧版驱动,分页SQL可能会有兼容问题。

3. 数据库建模:从耗材字典到批次库存再到业务单据

数据库设计是本项目最值得花时间的部分。我第一版之所以返工,就是因为库存表少设计了批次字段。重新设计时,我把它拆成了三张核心表:耗材字典表、批次库存表、业务单据表,外加若干张辅助表。主数据管“有什么”,库存表管“存了多少、放在哪个批次”,单据表管“业务怎么流动”。

3.1 耗材字典表:把一物一码做对

耗材字典是整个系统的基础,字段设计直接影响后续所有模块。我最终保留的核心字段大致如下:

字段名类型说明
idbigint主键
material_codevarchar(64)耗材编码,全局唯一
material_namevarchar(128)耗材通用名称
specvarchar(128)规格型号
unitvarchar(32)最小使用单位
manufacturervarchar(128)生产厂家
registration_novarchar(128)医疗器械注册证号
material_typechar(2)01-低值,02-高值,03-检验试剂
management_categoryvarchar(32)医院自定义管理分类
statustinyint0启用,1停用

这里最不应该省的是material_code,它才是真正的主数据标识。业务单据里不要直接存名称,一个编码对应一条字典记录,后面改厂家、改规格都容易维护。如果担心名称重复,就给material_code建立唯一索引,入库和出库接口都先按编码校验。

3.2 批次库存表:效期跟踪的核心

批次库存表是系统里最有技术含量的地方。我给它取了名叫material_stock,核心DDL思路如下:

CREATE TABLE material_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', material_id BIGINT NOT NULL COMMENT '耗材字典ID', batch_no VARCHAR(64) NOT NULL COMMENT '生产批次号', expire_date DATE NOT NULL COMMENT '有效期至', quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存数量(最小使用单位)', safety_stock INT DEFAULT 0 COMMENT '安全库存阈值', location_code VARCHAR(64) COMMENT '货位编码', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL COMMENT '创建时间', update_time DATETIME NOT NULL COMMENT '更新时间', UNIQUE KEY uk_material_batch (material_id, batch_no) ) COMMENT '耗材批次库存表';

批次号和有效期必须绑定在一起。入库同一批耗材时,如果已有相同批次号,就在原记录上增加数量;如果新批次,就插入一条新记录。不要试图在库存表里存一个总数字段而不分批次,否则后面效期预警、先进先出这些功能全都做不下去。

这里还要补一个容易踩的细节:有部分报表需求需要按总数量统计,我额外加了一个冗余字段或者直接通过SUM(quantity)聚合,但没有为了统计方便而丢掉批次维度。宁可查询稍复杂,也不能阉割批次模型。

3.3 单据表:入库单、出库单与明细如何关联

业务单据按主表和明细表两层设计。以出库单为例,主表记录单号、科室、出库类型、状态、操作人、出库时间,明细表记录耗材、批次号、出库数量、有效期,所以基本结构如下:

CREATE TABLE outbound_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '出库单号', department_id BIGINT NOT NULL COMMENT '领用科室', order_type VARCHAR(16) COMMENT '出库类型:请领出库/报损出库/调拨出库', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待审核 1待出库 2已完成 3已驳回', apply_user VARCHAR(32), outbound_time DATETIME, create_time DATETIME NOT NULL ); CREATE TABLE outbound_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, batch_node VARCHAR(64) COMMENT '实际出库批次', quantity INT NOT NULL, price DECIMAL(10,2) COMMENT '出库单价,便于财务结算' );

出库单号我用日期加流水号生成,比如CK20250415001,用唯一索引保证幂等。主表和明细表必须参与同一个数据库事务,以免出现主表状态已改、明细却丢失的脏数据。

4. 核心业务接口实现:出入库流程与并发扣库存

表结构设计好了以后,真正的业务代码反而清晰了很多。我把关键流程拆成三个步骤来讲:入库怎么做、出库怎么做、库存并发扣减怎么做。这三个步骤是整个系统最容易被写崩的地方。

4.1 入库业务:一单多头、账实同步的做法

入库单一般来自采购订单或者供应商送货单。接口接收的参数是一个主单对象加一个明细集合,我建议入库先做数据校验,再依次完成三步操作:

  1. 校验耗材字典编码是否有效,校验批次号和有效期是否在合理范围。
  2. 插入入库单主记录和明细记录,状态置为“已入库”。
  3. 循环处理每条明细,如果在material_stock表里已存在同样的materialId和batchNo,则执行数量累加;否则新增一条批次库存记录。所有操作包在一个事务里。

这里有一个容易忽略的问题:入库数量必须同时更新库存表和入库明细表,如果分开提交,一旦中间报错,账实就会出现偏差。因此我习惯把Service方法标注为@Transactional(rollbackFor = Exception.class),确保任何异常都会回滚整个事务。

4.2 出库业务:按效期优先挑选批次

出库逻辑比入库要复杂,因为一张出库单可能对应多个批次。系统在收到请领单后,会做以下动作:

  1. 出库前先按物料编码汇总请领数量。
  2. 查询该物料所有剩余批次,按过期日期正序排列,也就是近效期先出。
  3. 按顺序逐个批次扣减,一个批次不够就扣下一个批次,直到数量足够。
  4. 如果所有批次总库存都不足,系统提示“库存不足”,并允许库房选择部分出库。

批次选择策略的SQL方向大概是这样:

SELECT * FROM material_stock WHERE material_id = #{materialId} AND quantity > 0 ORDER BY expire_date ASC, batch_no ASC;

实际扣减时,我会在事务里逐批次执行条件更新,而不是查询后修改再写回,原因见下一节。

4.3 并发扣库存:不读改再写,用条件更新保证安全

扣库存最怕超发。常规直觉是先查库存够不够,够就减数量,再存回去,但这套流程在并发场景下很容易出问题。两个请求同时查到剩余数量为10,又同时扣到6,最后一次更新就会覆盖前一次结果,实际上只扣了一次库存。

正确方式是用条件更新直接改并靠影响行数判断结果。我封装了一个Mapper方法:

@Update("UPDATE material_stock " + "SET quantity = quantity - #{quantity}, version = version + 1 " + "WHERE id = #{stockId} AND quantity >= #{quantity}") int deductStock(@Param("stockId") Long stockId, @Param("quantity") Integer quantity);

这段SQL的核心价值在于:它把“判断库存是否够”和“扣减库存”放到同一条UPDATE语句里完成。数据库行锁会保证同一时刻只有一个事务真正更新这条记录,如果影响行数为0,说明库存被其他请求先扣了,或者数量确实不足,这时直接抛出异常并回滚。单机部署下这种行锁方案完全够用,不需要一开始就上Redis锁或分布式锁,过度设计在医院小规模系统里只会增加维护成本。

5. 效期预警、统计报表与系统交付

基础业务跑通之后,这个项目真正“智能”的部分开始浮现,主要是三块:效期预警、定期盘点、统计报表。这三块更多是配置和数据处理逻辑,没有太多复杂业务,但直接影响用户的日常使用体验。

5.1 效期预警:定时任务扫描与预警记录表

效期管理是医疗耗材系统绕不开的痛点。我通过Spring Task的@Scheduled注解做了一个每日任务,定时扫描未来三个月内到期的库存批次,并把预警结果写入独立表。示意图如下:

@Component public class ExpiryWarningTask { @Scheduled(cron = "0 30 8 * * ?") public void scanExpiringStock() { LocalDate threshold = LocalDate.now().plusMonths(3); List<MaterialStock> list = stockMapper.selectExpiringList(threshold); for (MaterialStock item : list) { // 判断是否已存在相同批次的预警记录 // 不存在则插入,状态设置为“未处理” } } }

需要注意两点:一是定时任务的频率不要设置得太高,每天一次完全足够,否则对数据库压力反而明显;二是预警记录要做幂等处理,同一天同一批次不要反复插入多条重复记录。我还在系统界面上做了一个待办中心,把未处理的预警批次展示在首页,操作人员处理完一批,就把预警状态改成“已处理”,生成处理记录,这样后续审计可以追溯。

5.2 统计报表:按科室、耗材分类汇总

统计模块我把需求简化成三类报表:科室领用次数与金额、库房库存效期清单、供应商供货记录。如果只是为了管理层看整体趋势,直接在SQL层面按条件聚合就足够,不需要引入大数据组件。类似下面的写法,基本能满足月和年维度汇总:

SELECT department_id, DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(quantity * price) AS total_amount FROM outbound_order WHERE status = 2 GROUP BY department_id, DATE_FORMAT(create_time, '%Y-%m') ORDER BY month DESC;

我用一个独立页面做报表查询,按时间范围筛选,导出功能直接使用前端表格流,把当前查询结果导出成Excel。考虑到医院实际数据量不大,这种实现成本低,维护起来也轻松,不要为了“智能”两个字硬上复杂架构。

5.3 从开发到部署的要点

部署阶段我踩过一个小坑,这里提前讲:Spring Boot项目用mvn clean package -DskipTests打包后,生成的jar文件会包含默认的application.yml,但生产环境的数据库连接等信息不应该写死在jar里。更稳妥的做法是启动时用外部配置文件覆盖,比如:

java -jar hospital-system.jar --spring.profiles.active=prod

同时要确保jar包目录下有可写的文件上传路径,不要把上传文件写到应用运行目录里,否则重新部署很容易丢失数据。前端构建出来的静态文件可以直接放在Nginx里,由Nginx反代到后端接口,处理跨域问题也更规范。

6. 我在实际开发里遇到的坑和最终调整

做这个项目的过程中,最有价值的反而不是功能本身,而是那几个让我调了很久才解决的细节问题。我把其中一部分整理出来,给后来项目提个醒。

6.1 Spring Boot版本和MyBatis兼容性的坑

项目刚开始时,我和同事各自拉了一个分支,他用了Spring Boot 3.0,我用了2.7,结果中间合并时发现MyBatis-Plus的旧版本在3.0下启动会报一堆类找不到。后来统一回退到2.7.x权当最省心方案。如果确有需要升级到Boot 3,请先确认四件事:JDK版本升级到17以上、MyBatis-Plus或MyBatis版本是否支持、分页插件配置是否兼容、以及所有javax.*相关依赖是否更换为jakarta.*,否则启动阶段排查会花费大量时间。

6.2 LocalDateTime在JSON序列化时格式不好看

场景是这样的:后端字段用的是LocalDateTime,默认序列化出来的值带T字符,例如2025-04-15T10:30:00,前端展示时还得再格式化,而且不同浏览器解析行为不完全一致。我的解决方案是在application.yml里统一配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这样后端的日期时间对象在输出JSON时就会自动按统一格式展示,前端拿到的就是直观字符串,基本不需要二次处理。

6.3 上传的图片文件到底存在哪里

系统里会有供应商证照、注册证、验收单等附件需要上传。最开始我把文件保存到项目运行目录下的某个临时文件夹,后面重新部署就丢了好几个附件,被用户反馈坑了一次。后来我改成在系统配置项里设置一个独立的文件存储路径,比如/data/hospital-files,由运维保证该目录的定时备份。如果项目已经引入了MinIO这类对象存储服务,也可以直接对接,但瓶颈不在存储本身,而在文件路径映射是否可配置。医院这种小规模系统,一开始做一个可配置的外置目录就够了,没必要为了秀技术引入不必要的组件。

6.4 常用问题的排查清单

最后,我把开发过程中遇到过得比较多的几个问题整理成了一张表,方便排查:

现象可能原因处理办法
启动报错找不到驱动类MySQL驱动版本与JDK不匹配检查MySQL Connector/J版本,使用与JDK匹配的驱动
分页不生效分页插件没有配置拦截器在MyBatis配置中加入分页插件并指定数据库类型
删除数据后列表页仍能查出来逻辑删除字段没有在查询Mapper中过滤确认Mapper语句中带上deleted条件
高并发下库存变负数使用了查询后再更新而不是条件更新改为UPDATE ... WHERE quantity >= ?方式
定时任务执行多次部署了多个实例单机部署即可;如需多实例,考虑任务调度锁

这些坑都不算高级,但每一件在实际运行中都会让人花不少时间。具体项目开发时,遇到问题先判断是环境问题还是代码逻辑问题,再逐步缩小排查范围,通常比直接搜“报错关键字”更有效。

这个项目做到最后,我的最大体会是:系统的价值不在于用了多少新技术,而在于库存批次、效期、单据流转这些细节是否设计得足够准。把这些基础工作做扎实,后续的扩展空间才会有,也不至于在交付后被真实业务问得哑口无言。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 13:07:11

STM32入门核心逻辑:从芯片架构到实战调试的可迁移方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 13:07:04

Bayes-ISSA-BP神经网络回归:MATLAB多输入单输出预测与参数优化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 13:05:32

Python接入XTP极速交易系统:从零到实盘的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 13:02:33

MARKDOWN 1

CESHIzhanjukengweidengdaifabiao

作者头像 李华
网站建设 2026/10/2 13:02:03

地面无人作战平台性能评价:机动性、自主性指标体系的构建与落地

简介&#xff1a;《地面无人作战平台性能评价指标体系》是一篇发表于2012年的PDF格式论文&#xff0c;面向地面无人作战平台研发人员、装备论证与采购人员&#xff0c;解决当前缺乏统一性能评价标准、各方对平台性能描述不一致的问题。资源从无人作战平台的内涵出发&#xff0c…

作者头像 李华
网站建设 2026/10/2 13:01:35

HowToCook 炒馍做法详解:用隔夜馒头炒出外脆里软的北方家常主食

文档教程 【免费下载链接】HowToCook Programmers guide about how to cook at home. 项目地址&#xff1a; https://gitcode.com/GitHub_Trending/ho/HowToCook 点击查看 免费下载 本篇技术指南以开源仓库 HowToCook 中 炒馍.md 为骨架&#xff0c;完整梳理炒馍这道北方家常主…

作者头像 李华