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.xJDK用1.8/11都行网上能搜到的资料也比较完整。我身边有同学直接上Spring Boot 3必须配JDK 17MyBatis相关的老依赖在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则弹错误提示。这样做的好处是后续加全局异常处理时只需要改造一个地方前端也不会因为不同接口返回结构不同而写一堆兼容代码。分页我直接封装了PageResultMyBatis-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 入库业务一单多头、账实同步的做法入库单一般来自采购订单或者供应商送货单。接口接收的参数是一个主单对象加一个明细集合我建议入库先做数据校验再依次完成三步操作校验耗材字典编码是否有效校验批次号和有效期是否在合理范围。插入入库单主记录和明细记录状态置为“已入库”。循环处理每条明细如果在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); ListMaterialStock 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.activeprod同时要确保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: GMT8这样后端的日期时间对象在输出JSON时就会自动按统一格式展示前端拿到的就是直观字符串基本不需要二次处理。6.3 上传的图片文件到底存在哪里系统里会有供应商证照、注册证、验收单等附件需要上传。最开始我把文件保存到项目运行目录下的某个临时文件夹后面重新部署就丢了好几个附件被用户反馈坑了一次。后来我改成在系统配置项里设置一个独立的文件存储路径比如/data/hospital-files由运维保证该目录的定时备份。如果项目已经引入了MinIO这类对象存储服务也可以直接对接但瓶颈不在存储本身而在文件路径映射是否可配置。医院这种小规模系统一开始做一个可配置的外置目录就够了没必要为了秀技术引入不必要的组件。6.4 常用问题的排查清单最后我把开发过程中遇到过得比较多的几个问题整理成了一张表方便排查现象可能原因处理办法启动报错找不到驱动类MySQL驱动版本与JDK不匹配检查MySQL Connector/J版本使用与JDK匹配的驱动分页不生效分页插件没有配置拦截器在MyBatis配置中加入分页插件并指定数据库类型删除数据后列表页仍能查出来逻辑删除字段没有在查询Mapper中过滤确认Mapper语句中带上deleted条件高并发下库存变负数使用了查询后再更新而不是条件更新改为UPDATE ... WHERE quantity ?方式定时任务执行多次部署了多个实例单机部署即可如需多实例考虑任务调度锁这些坑都不算高级但每一件在实际运行中都会让人花不少时间。具体项目开发时遇到问题先判断是环境问题还是代码逻辑问题再逐步缩小排查范围通常比直接搜“报错关键字”更有效。这个项目做到最后我的最大体会是系统的价值不在于用了多少新技术而在于库存批次、效期、单据流转这些细节是否设计得足够准。把这些基础工作做扎实后续的扩展空间才会有也不至于在交付后被真实业务问得哑口无言。