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 耗材字典表:把一物一码做对
耗材字典是整个系统的基础,字段设计直接影响后续所有模块。我最终保留的核心字段大致如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| material_code | varchar(64) | 耗材编码,全局唯一 |
| material_name | varchar(128) | 耗材通用名称 |
| spec | varchar(128) | 规格型号 |
| unit | varchar(32) | 最小使用单位 |
| manufacturer | varchar(128) | 生产厂家 |
| registration_no | varchar(128) | 医疗器械注册证号 |
| material_type | char(2) | 01-低值,02-高值,03-检验试剂 |
| management_category | varchar(32) | 医院自定义管理分类 |
| status | tinyint | 0启用,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 入库业务:一单多头、账实同步的做法
入库单一般来自采购订单或者供应商送货单。接口接收的参数是一个主单对象加一个明细集合,我建议入库先做数据校验,再依次完成三步操作:
- 校验耗材字典编码是否有效,校验批次号和有效期是否在合理范围。
- 插入入库单主记录和明细记录,状态置为“已入库”。
- 循环处理每条明细,如果在
material_stock表里已存在同样的materialId和batchNo,则执行数量累加;否则新增一条批次库存记录。所有操作包在一个事务里。
这里有一个容易忽略的问题:入库数量必须同时更新库存表和入库明细表,如果分开提交,一旦中间报错,账实就会出现偏差。因此我习惯把Service方法标注为@Transactional(rollbackFor = Exception.class),确保任何异常都会回滚整个事务。
4.2 出库业务:按效期优先挑选批次
出库逻辑比入库要复杂,因为一张出库单可能对应多个批次。系统在收到请领单后,会做以下动作:
- 出库前先按物料编码汇总请领数量。
- 查询该物料所有剩余批次,按过期日期正序排列,也就是近效期先出。
- 按顺序逐个批次扣减,一个批次不够就扣下一个批次,直到数量足够。
- 如果所有批次总库存都不足,系统提示“库存不足”,并允许库房选择部分出库。
批次选择策略的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 >= ?方式 |
| 定时任务执行多次 | 部署了多个实例 | 单机部署即可;如需多实例,考虑任务调度锁 |
这些坑都不算高级,但每一件在实际运行中都会让人花不少时间。具体项目开发时,遇到问题先判断是环境问题还是代码逻辑问题,再逐步缩小排查范围,通常比直接搜“报错关键字”更有效。
这个项目做到最后,我的最大体会是:系统的价值不在于用了多少新技术,而在于库存批次、效期、单据流转这些细节是否设计得足够准。把这些基础工作做扎实,后续的扩展空间才会有,也不至于在交付后被真实业务问得哑口无言。