SSM+MySQL医院物资管理系统:从库存建模到并发扣减落地实践
发布时间:2026/9/12 0:55:34来源:尧图网络
简介这套医院院内物资管理系统是一套完整可运行的项目源码基于Java语言与SSM框架开发后台使用MySQL数据库同时配有数据库初始化脚本和项目说明文档。资源面向Java Web初学者及进阶学习者既适合作为毕业设计、课程设计、大作业或工程实训的选题也可直接用于学习借鉴和二次开发。压缩包共包含1437个文件主要涵盖Java源码、JSP页面、XML配置、JAR依赖库以及JavaScript、CSS、HTML等前端资源还有PNG、GIF图片素材整体大小约46.31MB目录结构组织清晰覆盖后端业务逻辑、前端展示层与数据库脚本能帮助读者完整理解SSM项目的架构与协作方式。开发环境采用JDK1.8、Tomcat7、Maven3.3.9兼容Eclipse、MyEclipse、IDEA等常见开发工具目前已有51人学习使用。源码经过精心调试并附带文档可快速部署运行项目具备较好的学习价值遇到问题也可与博主沟通获得支持。1. 医院院内物资管理系统SSM 技术栈下要解决的核心问题一个标注为ssm-011-mysql的医院物资管理项目技术栈无疑就是 Spring SpringMVC MyBatis数据库选 MySQL。但这类系统的重点从来不在框架本身而在于医院场景下物资管理的特殊需求高值耗材按批次和追溯码追踪、药品有近效期管理、低值耗材按科室领用、手术耗材需要扫码出库并关联到患者。如果只做一张material表和几个增删改查接口上线第一周就会被库房管理员打回。这篇博文面向正在做 SSM 项目或电力、医疗、政务等传统行业信息系统的 Java 工程师。MySQL 表结构怎么设计才能兼顾批次和流水追溯SSM 三层里事务边界放哪一层科室领用并发扣库存时怎么避免超发近效期预警是写定时任务还是用 SQL 直接查。下面从 MySQL 建模开始逐个把方案和代码讲清楚。2. MySQL 侧先建模主数据、批次库存与流水表的三层设计2.1 物资主数据表编码唯一和类别字段决定了后续统计口径很多 SSM 项目的失败起点是一张material表堆了十几列既有名称、规格、单位又塞库存数量、供应商和有效期。医院场景下这样设计有两个直接问题同一药品不同采购批次的有效期不同库存无法按批次核算高值耗材需要按唯一码追踪到患者单表结构装不下。正确做法是把数据拆成三张核心表material维护物资字典material_batch_stock维护某物资在某批号下的实时库存stock_record记录每一笔出入库流水。这样盘点、近效期查询、批次追溯各自有清晰的落点。先看material的建表脚本。CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, material_code VARCHAR(32) NOT NULL COMMENT 物资编码, material_name VARCHAR(128) NOT NULL COMMENT 物资名称, spec VARCHAR(64) DEFAULT COMMENT 规格型号, unit VARCHAR(16) NOT NULL COMMENT 计量单位, category TINYINT NOT NULL COMMENT 1-药品 2-高值耗材 3-低值耗材 4-设备, is_deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_code (material_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT物资主数据表;material_code建唯一索引是最容易被忽略的约束。物资编码在字典里出现两条记录后面所有库存统计和流水对账都会翻倍。category字段用TINYINT存储而不是直接写字符串能让后续按类别做的库存预警、科室领用统计直接走索引避免在 MySQL 里做WHERE category 高值耗材这种低效比较。2.2 批次库存表DECIMAL 精度和有效期索引material_batch_stock不重复存物资名称只通过material_id引用主数据。它需要存批号、有效期、数量和追溯码。高值耗材场景下bar_code保存医保耗材码、厂家追溯码或医院内部生成的条码后续扫码出库直接按这个字段查询。CREATE TABLE material_batch_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, material_id BIGINT NOT NULL COMMENT 物资ID关联material.id, batch_no VARCHAR(64) NOT NULL COMMENT 生产/采购批号, expire_date DATE DEFAULT NULL COMMENT 有效期药品和耗材必填, quantity DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT 当前实存数量, bar_code VARCHAR(64) DEFAULT COMMENT 追溯码或院内条码, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_material_batch (material_id, batch_no), KEY idx_expire (expire_date), CONSTRAINT fk_stock_material FOREIGN KEY (material_id) REFERENCES material(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT批次库存表;quantity用DECIMAL(12,2)而不是INT因为低值耗材中纱布、棉签按「包」或 卷 计量拆包发放会出现小数点。如果只做药品管理可以改成DECIMAL(10,2)但对医院全院物资来说保留两位小数更稳妥。uk_material_batch联合唯一约束保证了同一物资同一批号只存在一行这是后续入库时「累加还是新增」的判断依据。idx_expire索引服务近效期查询后面会看到具体 SQL。2.3 流水表before_qty 和 after_qty 是对账的关键字段stock_record是所有库存变动的审计底账。医院物资每年都要应对内部审计和上级检查审计人员会问「3 月 15 日那批介入耗材发给哪个科室了、谁签收的」。流水表如果只存material_id和change_qty根本无法回答这类问题。CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_no VARCHAR(32) NOT NULL COMMENT 单号入库单号/出库单号, material_id BIGINT NOT NULL COMMENT 物资ID, batch_id BIGINT NOT NULL COMMENT 批次库存ID, change_type TINYINT NOT NULL COMMENT 10-采购入库 20-科室领用 30-报损 40-盘点调整 50-退货, change_qty DECIMAL(12, 2) NOT NULL COMMENT 正数入库负数出库, before_qty DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT 变动前库存, after_qty DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT 变动后库存, dept_id BIGINT DEFAULT NULL COMMENT 科室ID领用时必填, operator VARCHAR(32) NOT NULL COMMENT 操作人, remark VARCHAR(255) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_record_no (record_no), KEY idx_material_time (material_id, create_time), KEY idx_batch (batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存变动流水表;before_qty和after_qty是新手项目最容易漏掉的两个字段。有了它们每次业务操作就变成「读当前库存 → 计算新库存 → 流水写入前后值」。库存对不上时直接根据流水里这两个字段回溯是哪一笔操作改错了不需要翻应用日志更不需要为难运维同事。2.4 MySQL 事务与并发扣减条件 UPDATE 替代 SELECT FOR UPDATE科室领用耗材时两个护士台同时提交领用单如果后端先select quantity再update线程并发下一定出现超领。解决办法是让「判断库存够不够」和「扣数量」合并成一条 SQL。UPDATE material_batch_stock SET quantity quantity - #{changeQty} WHERE id #{batchId} AND quantity #{changeQty}执行这条语句时InnoDB 对命中行加排他锁影响行数如果返回 0 说明库存不足Service 层直接抛异常。锁会一直保持到事务提交才释放流水写入发生在同一事务里不会出现库存扣了但流水没写的中间状态。比起先SELECT ... FOR UPDATE再计算这种方式少了一次查询代码也更简洁。MySQL 8.0 建库推荐统一使用utf8mb4_general_ci字符集。生产环境迁移时遇到过连接串字符集与库表排序规则不一致的情况导致 MyBatis 中文条件匹配时扫全表。建库语句建议是CREATE DATABASE hospital_mis DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;2.5 SSM 数据源连接的时区与驱动参数MySQL 8.0 的连接驱动是com.mysql.cj.jdbc.Driver连接串里必须有serverTimezoneAsia/Shanghai否则写入 DATETIME 字段时会按 UTC 计算差 8 小时。Druid 数据源配置如下bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/hospital_mis?useUnicodetrueamp;characterEncodingUTF-8amp;useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueyour_password/ property nameinitialSize value5/ property namemaxActive value20/ property namemaxWait value10000/ property nametestWhileIdle valuetrue/ property namevalidationQuery valueSELECT 1/ /beanmaxWait10000表示获取连接时最长等待 10 秒。医院物资系统在月底集中出库时连接池如果被占满后续线程不至于无限阻塞10 秒后直接报错这样问题能立刻暴露出来。testWhileIdle在连接归还后做探活避免 MySQL 服务端wait_timeout把连接回收后应用还拿着失效连接执行 SQL。3. SSM 分层落地Spring 容器、MyBatis Mapper 与事务配置3.1 Maven 依赖版本SSM 三件套的搭配原则SSM 项目虽然不算新但版本配错会浪费大量时间。以目前稳定可用的组合为例mybatis-spring2.1.x 与 Spring 5.3.x 经过了大量生产项目验证不要随便升到 3.x 或 Spring 6.x否则SqlSessionTemplate初始化时会报NoSuchBeanDefinitionException。核心依赖坐标如下dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.39/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.39/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency大家搭建 SSM 时容易踩的坑是 Web 容器内置了旧版javax.servlet-api导致 SpringMVC 的DispatcherServlet初始化失败。解决方法是把servlet-api的依赖范围设为provided由 Tomcat 运行时自己提供。3.2 MyBatis Mapper 接口与 XML 的对应关系Mapper 接口里写方法签名XML 文件里写 SQL两者通过命名空间绑定。关键约定是接口全限定名与 XML 的namespace一致方法名与 XML 里的id一致。下面是一个通用的库存查询 Mapper。public interface BatchStockMapper { BatchStock selectByMaterialAndBatch(Param(materialId) Long materialId, Param(batchNo) String batchNo); int decreaseQuantity(Param(id) Long id, Param(changeQty) BigDecimal changeQty); }XML 中对应的decreaseQuantity使用前面提到的条件更新写法update iddecreaseQuantity UPDATE material_batch_stock SET quantity quantity - #{changeQty} WHERE id #{id} AND quantity #{changeQty} /update这里返回类型是int代表影响行数。如果返回 0调用方就可以判断扣减失败。注意changeQty使用BigDecimal类型接收避免前端传入浮点数导致精度丢失。3.3 Service 层事务边界的正确位置库存 操作的Transactional注解必须加在 Service 方法上而不是 Controller 方法。原因有两层Controller 一个方法可能调用多个 Service如果注解加在 Controller 上所有 Service 操作都在一个事务里粒度太大另外 Controller 层做参数校验后直接抛异常异常如果跨过事务边界会触发回滚但异常信息在 Controller 里拿不到。以入库操作 Service 实现为例Service public class StockServiceImpl implements StockService { Resource private MaterialMapper materialMapper; Resource private BatchStockMapper batchStockMapper; Resource private StockRecordMapper stockRecordMapper; Override Transactional(rollbackFor Exception.class) public void inbound(InboundDTO dto) { Material material materialMapper.selectByCode(dto.getMaterialCode()); if (material null) { throw new BusinessException(物资编码不存在先维护主数据); } BatchStock stock batchStockMapper .selectByMaterialAndBatch(material.getId(), dto.getBatchNo()); if (stock null) { BatchStock newStock new BatchStock(); newStock.setMaterialId(material.getId()); newStock.setBatchNo(dto.getBatchNo()); newStock.setExpireDate(dto.getExpireDate()); newStock.setQuantity(dto.getQuantity()); batchStockMapper.insert(newStock); } else { batchStockMapper.increaseQuantity(stock.getId(), dto.getQuantity()); } stockRecordMapper.insert(buildInboundRecord(material, stock, dto)); } }Transactional(rollbackFor Exception.class)必须写明。Spring 默认只对RuntimeException回滚如果BusinessException继承的是Exception事务不会回滚结果就是库存已经变了但流水没写数据直接对不上。入库的顺序是先更新批次库存再写流水。如果先写流水再更新库存更新失败时流水已经插入回滚后流水记录消失但单号可能已经被占用。3.4 Controller 层的标准写法只做参数校验和视图转发Controller 不写任何 SQL 或业务判断只做参数接收、简单校验、调用 Service、返回结果。SpringMVC 中一个入库接口的典型写法如下Controller RequestMapping(/stock) public class StockController { Resource private StockService stockService; RequestMapping(value /inbound, method RequestMethod.POST) ResponseBody public Result inbound(RequestBody InboundDTO dto) { if (dto.getMaterialCode() null || dto.getMaterialCode().isEmpty()) { return Result.error(物资编码不能为空); } if (dto.getQuantity() null || dto.getQuantity().signum() 0) { return Result.error(入库数量必须大于0); } try { stockService.inbound(dto); return Result.success(); } catch (BusinessException e) { return Result.error(e.getMessage()); } } }RequestBody要求前端提交 JSON 格式数据Result是一个统一的返回值封装类包含code、message、data三个字段。把参数校验放在 Controller 是为了让 Service 方法只处理正常业务不需要反复判断空值而真正的业务规则比如库存扣减失败在 Service 里抛异常Controller 只负责捕获后转成错误信息。4. 核心业务实现入库、科室领用、盘点与近效期预警4.1 高值耗材入库扫码枪录入与批次绑定高值耗材入库和普通药品入库的区别在于耗材有唯一的追溯码入库时要把追溯码、批号、生产日期、有效期全部绑定到批次库存上。医院常用的 UDIUnique Device Identification码通过扫码枪录入后端接到的是一串包含厂商代码、产品代码、批号、序列号的字符串。解析逻辑一般用正则拆解 GS1 标准的应用标识符public UdiInfo parseUdi(String udi) { Pattern pattern Pattern.compile( \\(01\\)(\\d{14})\\(10\\)([A-Z0-9]{1,20})\\(17\\)(\\d{6})\\(21\\)([A-Z0-9]{1,20})); Matcher matcher pattern.matcher(udi); if (matcher.matches()) { UdiInfo info new UdiInfo(); info.setGtin(matcher.group(1)); info.setBatchNo(matcher.group(2)); String expire matcher.group(3); info.setExpireDate(LocalDate.of( 2000 Integer.parseInt(expire.substring(0, 2)), Integer.parseInt(expire.substring(2, 4)), Integer.parseInt(expire.substring(4, 6)))); info.setSerialNo(matcher.group(5)); return info; } throw new BusinessException(UDI码格式不正确); }UDI 中的(17)是有效期标识格式为YYMMDD解析时要注意00到99映射到2000到2099。入库完成后要把追溯码serial_no与批次库存的行id做成关联表方便后续出库扫码直接反查。高值耗材管理中设备类物资没有 UDI入库走条码规则中的(01)GTIN就够了。4.2 入库业务的库存更新策略批号优先合并同一批号二次入库处理方式应该是累加而不是新建一行。判断依据就是uk_material_batch联合唯一索引。在 Service 里先查后插或查后更新MyBatis 操作如下public void inbound(InboundDTO dto) { BatchStock stock batchStockMapper .selectByMaterialAndBatch(dto.getMaterialId(), dto.getBatchNo()); if (stock null) { BatchStock newStock new BatchStock(); newStock.setMaterialId(dto.getMaterialId()); newStock.setBatchNo(dto.getBatchNo()); newStock.setExpireDate(dto.getExpireDate()); newStock.setQuantity(dto.getQuantity()); batchStockMapper.insert(newStock); } else { batchStockMapper.increaseQuantity(stock.getId(), dto.getQuantity()); } stockRecordMapper.insert(StockRecordBuilder.inbound(dto, ...)); }increaseQuantity对应 SQL 是UPDATE material_batch_stock SET quantity quantity #{qty} WHERE id #{id}。这里不能用quantity 1这种 Python 风格的写法MyBatis 的#{}参数占位符会自动转义SQL 注入问题从源头避免。4.3 科室领用出库先锁定批次再扣减科室领用是医院物资系统里最高频的操作并发压力也最大。护士站多个终端同时点领用如果不加控制后提交的请求覆盖前面的扣减结果库存数量会变成负数。解决思路是先锁行、再校验、最后扣减Override Transactional(rollbackFor Exception.class) public void deptOutbound(OutboundDTO dto) { BatchStock stock batchStockMapper.selectByIdForUpdate(dto.getBatchId()); if (stock null) { throw new BusinessException(批次库存不存在); } if (stock.getQuantity().compareTo(dto.getQuantity()) 0) { throw new BusinessException(库存不足剩余 stock.getQuantity()); } batchStockMapper.decreaseQuantity(stock.getId(), dto.getQuantity()); stockRecordMapper.insert(buildOutboundRecord(dto, stock)); }selectByIdForUpdate对应 SQL 是SELECT * FROM material_batch_stock WHERE id #{id} FOR UPDATE。这条语句在 InnoDB 中锁定该行其他事务要更新同一行时会被阻塞直到本事务提交。compareTo方法用于BigDecimal比较不能用运算符。科室领用必须有明确的领用部门dept_id这是核算科室成本的基础。代码里dept_id不从前端传而是从登录用户的 Session 或 Token 解析出来的保证不会出现护士长替内科领了外科物资审计时说不清楚。4.4 MySQL 存储过程生成领用单号医院物资系统的单号格式通常是YYMMDD 科室编号 流水号比如250318KH001。在高并发下用 Java 的System.currentTimeMillis()拼单号会产生重复更稳妥的办法是交给 MySQL 处理利用LAST_INSERT_ID(expr)生成连续序列。常见方案是建一张单号序列表CREATE TABLE sequence_no ( biz_type VARCHAR(32) PRIMARY KEY COMMENT 业务类型, current_value BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 );发号时不直接UPDATE而是用UPDATE ... SET current_value LAST_INSERT_ID(current_value 1)然后通过SELECT LAST_INSERT_ID()取新值。这种方案的好处是单号生成与业务的事务完全隔离不会因为业务回滚而跳号或重号。具体写法UPDATE sequence_no SET current_value LAST_INSERT_ID(current_value 1), version version 1 WHERE biz_type LY;应用层拿到新序号后拼接业务前缀和日期生成最终单号。这个方式比使用 UUID 做单号更适合医院物资系统因为审计人员和库房管理员都习惯读有含义的单号UUID 粘贴到 Excel 里还会被当成科学计数法。4.5 近效期预警SQL 直接筛出来药品和部分耗材必须在有效期内使用近效期预警是医院物资系统区别于普通进销存系统的特征功能。预警逻辑不需要定时任务去全表扫描更合理的做法是提供一个按天变化的查询接口库房管理员打开页面时实时计算。SELECT m.material_code, m.material_name, s.batch_no, s.expire_date, s.quantity, DATEDIFF(s.expire_date, CURDATE()) AS remain_days FROM material_batch_stock s INNER JOIN material m ON m.id s.material_id WHERE s.expire_date IS NOT NULL AND s.quantity 0 AND DATEDIFF(s.expire_date, CURDATE()) BETWEEN 0 AND 90 ORDER BY s.expire_date ASC;DATEDIFF函数计算剩余天数BETWEEN 0 AND 90筛出三个月内到期的批次。这个 SQL 用到了idx_expire索引但DATEDIFF(s.expire_date, CURDATE())包裹了索引列MySQL 可能不会走索引。更高效是直接比较日期范围WHERE s.expire_date IS NOT NULL AND s.quantity 0 AND s.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) ORDER BY s.expire_date ASC;expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY)这种写法能直接利用idx_expire索引。90 天这个窗口参数可以用 MySQL 的预处理语句参数化也可以在前端做成下拉让库房管理员自己选择 30 天、60 天、90 天。此表结构是用DATE类型存有效期如果误用DATETIME或VARCHAR日期函数处理成本会直线上升索引也会失效。5. MySQL 侧三个必调参数与上线前的对账技巧医院物资管理系统上线前最后要处理的是 MySQL 连接池参数调优和数据一致性校验。maxWait这个参数前面提过maxActive20在大多数医院库房场景下够用。但要注意maxActive并不是越大越好MySQL 服务端的max_connections一般默认 151如果多个业务系统共用一台 MySQL每个连接都占用内存和线程资源。Druid 建议把minIdle设为和initialSize一致防止连接池在空闲时反复创建和销毁连接浪费数据库连接数。.jdbc驱动级参数rewriteBatchedStatementstrue对 SSM 项目有很大价值。批量插入流水记录或盘点明细时MySQL 会先把 SQL 预处理再发送这个参数能减少网络往返次数。入库批量操作时开启与不开启耗时差距通常在 3 到 5 倍。在 JDBC 连接串中追加jdbc:mysql://localhost:3306/hospital_mis?rewriteBatchedStatementstrueuseAffectedRowstrueuseAffectedRowstrue配合 MyBatis 的useGeneratedKeys使用保证返回的是 MySQL 真正受影响的行数而不是匹配的行数。流水表与库存表的一致性对账建议上线后每天凌晨由 MySQL 定时任务或应用定时任务执行一次SELECT r.material_id, r.batch_id, SUM(r.change_qty) AS total_change_qty, s.quantity AS stock_qty, (SUM(r.change_qty) - s.quantity) AS diff FROM stock_record r INNER JOIN material_batch_stock s ON s.id r.batch_id GROUP BY r.material_id, r.batch_id, s.quantity HAVING ABS(SUM(r.change_qty) - s.quantity) 0.01;这条 SQL 把仓库所有批次库存的历史累计变动和当前库存做对比差值超过 0.01 就说明有流水缺失或库存更新遗漏。跑出来的结果里diff为正表示流水比库存多说明库存扣减漏了为负表示库存比流水多说明入库时没写流水或手工改过库存。常见问题中Error 2002 (HY000): Cant connect to local MySQL server through socket /var/run/mysqld/mysqld.sock多出现在 SSM 项目本地启动时 MySQL 服务端未启动。解决办法是检查 MySQL 安装目录的数据文件是否初始化完成。这是 mysql 安装教程里最基础也最常复发的一步建议把 MySQL 注册成 Windows 服务或 Linux systemd 单元别让 IDE 每次启动时手动拉起数据库进程。医院物资系统的 SSM 工程跑通后再回头看这些表和 SQL库存流水完整批次可追溯并发扣减不会超量近效期预警用的是索引友好的写法对账 SQL 上线第一天就能查到老系统的期初差异。这套模式换到任何要管批次、管有效期、管审计追溯的进销存项目里改改表名就能复用。本文还有配套的精品资源点击获取
网站建设高端定制企业官网