医药进销存系统设计:批号效期管理、SSM事务与并发锁实践
简介这份基于Java的医药进销存管理系统源码是一套面向药店、医院药房及小型医药批发商的完整业务管理解决方案以Java为主要开发语言融合了面向对象设计、数据库操作与Web前端技术。压缩包共944个文件容量8.32MB包含105个Java源码、324个HTML页面、152个CSS样式、128个JS脚本以及SQL数据库脚本、PHP接口文件、XML配置、properties配置等覆盖前端展示、后端逻辑与数据存储资源中还包含76个GIF和37个PNG截图可预览系统界面ttf、woff等字体文件支撑前端显示效果。系统围绕药品采购、销售、库存查询与销售记录跟踪等核心业务展开采用MVC分层设计思路帮助开发者理解企业级Web项目的结构与流程。目前已有328人学习适合毕业设计、课程实训或小型医药项目二次开发参考。开发者可通过完整源码快速部署与二次开发结合数据库脚本、前后端代码及目录结构深入学习Java EE开发、数据库交互与进销存系统设计细节。1. 医药进销存比普通进销存多了什么药店要算一批药还有多少天到期、能不能拆零卖、一个批号被两个仓库同时扣库存怎么办——这些问题不是普通进销存能回答的。基于 Java 的医药进销存管理系统源码核心在于把“批号”“效期”“GSP 资质”这三样东西做成数据库结构和业务流程的一部分而不是在商品表上拼字段。这套代码能直接跑通采购入库、销售出库、库存预警四个闭环比网上那些纯 CRUD 的课程设计多了医药行业的业务细节。适合做毕业设计参考也适合拿来当 Java 事务和定时任务的复习素材改造成 Spring Boot 版本也不难。2. 解包源码从 war 包结构反推技术栈与核心表设计2.1 从 WEB-INF/lib 判断 SSM 而非 Spring Boot解压源码后不要急着导入 IDE先看WEB-INF/lib下的 jar 列表。之前接手的一套是 SSM 结构Spring MVC 负责路由、MyBatis 负责 SQL、Spring 管事务打包成 war 放进 Tomcat 运行。判断依据很直观——如果有spring-boot-starter-tomcat这类 jar才是 Spring Boot如果看到mybatis.jar、spring-webmvc.jar和一堆ojdbc或mysql-connector基本就是传统 SSM 工程。这套源码里还带了 Druid 连接池和 Jackson 的 jar说明序列化和数据库监控是跑通的。war 包的目录结构里WEB-INF/classes下通常找不到application.yml而是db.properties、spring-context.xml、spring-mvc.xml这一组文件。我的建议是先读spring-context.xml里context:property-placeholder指向的配置文件把所有数据源、Redis、文件上传路径摸清楚再开始看业务代码。很多毕设源码跑不起来的首要原因不是代码错而是数据库连接串写死、字符集不对。2.2 药品批次表医药进销存与普通进销存的分水岭普通进销存只需要商品表加库存表药品系统必须多一张批次表。原因是同一款药由不同供应商供货批号不同、有效期不同进货价也可能不同按商品维度记账会导致卖出去的药无法对应到具体批次效期一到就全员报废。药品批次表建表语句如下CREATE TABLE drug_batch ( batch_no VARCHAR(30) NOT NULL COMMENT 药品批号, product_code VARCHAR(20) NOT NULL COMMENT 药品编码, produce_date DATE DEFAULT NULL COMMENT 生产日期, expiry_date DATE NOT NULL COMMENT 有效期至, supplier_code VARCHAR(20) DEFAULT NULL COMMENT 供应商编码, purchase_price DECIMAL(10,2) DEFAULT NULL COMMENT 进货价, sale_price DECIMAL(10,2) DEFAULT NULL COMMENT 零售价, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1冻结 2过期, PRIMARY KEY (batch_no, product_code), KEY idx_product_expiry (product_code, expiry_date) ) COMMENT药品批次表;这段 SQL 有几个细节值得注意。主键用(batch_no, product_code)组合因为不同药品可以有相同批号同一药品的批号才需要唯一。idx_product_expiry索引把商品编码和效期放在一起后面的 FEFO 出库和近效期查询都依赖这个索引。status字段不是只在预警任务里更新销售出库时发现expiry_date早于当天也要同步置为 2否则预警 SQL 会重复扫描过期数据。药品主表drug_info则多出批准文号、包装规格、存储条件、生产企业等字段。GSP 要求记录首营企业资质所以供应商表supplier也带了gsp_cert_no、cert_expiry_date这类字段。普通进销存与医药进销存的差异可以概括如下维度普通进销存医药进销存库存维度商品商品 批号效期管理无生产日期、有效期至、近效期预警成本核算移动加权平均即可按批次进货价结转资质约束无供应商 GSP 证书、药品批准文号特殊状态有/无库存正常、冻结、过期、召回2.3 库存表按批号维度落库有了批次表库存表就必须以批号为粒度。drug_stock表保留商品代码、批号、仓库代码再配stock_qty和locked_qty两个数量字段。locked_qty很关键用于锁定预占库存比如销售单生成后、实际出库前先把数量从stock_qty挪到locked_qty避免两个销售人员同时卖掉同一批药。CREATE TABLE drug_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_code VARCHAR(20) NOT NULL, batch_no VARCHAR(30) NOT NULL, warehouse_code VARCHAR(10) NOT NULL, stock_qty INT NOT NULL DEFAULT 0, locked_qty INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_product_batch (product_code, batch_no, warehouse_code) ) COMMENT药品批次库存表;这里没有直接用批次表的主键做外键而是保留冗余的batch_no因为库存表查询频率高多一次 join 会拖慢出库扣减。version字段是预留的源码里如果用的是乐观锁更新更新时要把version带进 where 条件避免并发冲突。需要注意这张表与drug_batch的差异drug_batch存的是药品属性生产日期、价格、状态drug_stock存的是库存数量前者是“这一批药是什么”后者是“这一批药在哪、剩多少”。3. 采购入库的事务实现批次入库与锁库存3.1 验收登记的完整业务流采购入库在医药系统里不是一个 insert 能完成的。前端页面通常要录入供应商、发票号、药品明细、批号、生产日期、有效期、件数和拆零数保存时后端要做四件事写采购单主表、写采购明细、写批次表、更新库存表。这个流程必须在一个数据库事务里完成中间任何一步失败都要回滚否则会出现库存增加了但采购单没保存的脏数据。我建议在检查这套源码时先找到PurchaseService.purchase()方法看它有没有Transactional再往下看是不是用了insertBatch批量插入而不是循环单条 insert。循环单条插入不是不行但明细分录一多网络往返和日志刷盘的开销会放大通常 50 条以上就能感觉到卡顿。3.2 service 层入库方法拆解核心代码结构如下Transactional(rollbackFor Exception.class, timeout 30) public PurchaseResult purchase(PurchaseDTO dto) { // 1. 校验供应商资质是否有效 Supplier supplier supplierMapper.selectByCode(dto.getSupplierCode()); Assert.isTrue(supplier ! null VALID.equals(supplier.getStatus()), 供应商资质不通过); // 2. 写入批次表主键冲突说明批号重复 DrugBatch batch new DrugBatch(); batch.setBatchNo(dto.getBatchNo()); batch.setProductCode(dto.getProductCode()); batch.setExpiryDate(dto.getExpiryDate()); Assert.isTrue(batchMapper.insert(batch) 1, 批号重复或已存在); // 3. 锁定库存行防止并发叠加 DrugStock stock stockMapper.selectByProductForUpdate(dto.getProductCode(), dto.getWarehouseCode()); if (stock null) { stock new DrugStock(); stock.setProductCode(dto.getProductCode()); stock.setBatchNo(dto.getBatchNo()); stock.setStockQty(dto.getQuantity()); stockMapper.insert(stock); } else { stockMapper.increaseQty(stock.getId(), dto.getQuantity()); } return PurchaseResult.success(batch, stock); }这段代码有四个参数值得逐个看清楚。rollbackFor Exception.class表示遇到所有异常都回滚如果不写Spring 默认只对 RuntimeException 回滚受检异常抛出后事务照样提交这是最常见的隐蔽 bug。timeout 30是事务超时单位秒超过 30 秒数据库连接会强制回滚防止某条 SQL 锁等待过久拖垮连接池。Assert.isTrue来自 Spring 的断言工具条件不满足直接抛 IllegalArgumentException事务随即回滚。increaseQty对应的是 UPDATE 语句不是先查后改。3.3 行锁与事务边界的配合selectByProductForUpdate对应的 SQL 是SELECT * FROM drug_stock WHERE product_code #{productCode} AND warehouse_code #{warehouseCode} FOR UPDATE;这段 SQL 会对命中的库存行加排他锁锁在事务提交或回滚时释放。为什么要在这里加锁因为采购入库和销售出库可能同时操作同一行库存没有锁的话两个事务同时读到stock_qty 10一个加 5一个减 3最后结果可能是 12 或 15取决于哪个后提交而不是正确的 12。加FOR UPDATE之后第二个事务必须等第一个事务结束才能读这行数据一致性才有保证。要注意锁的范围。FOR UPDATE锁的是索引匹配到的行如果product_code没有索引MySQL 会扫描全表然后锁住所有匹配行极端情况下可能升级为表锁。所以库存表的idx_product_batch复合索引是必须要有的这就是 2.3 节建表时加唯一键和索引的原因。这套源码里如果发现锁粒度偏大优先检查 where 条件是否走了索引。3.4 并发方案取舍与排查点对比维度悲观锁 FOR UPDATE乐观锁 version适用场景批号库存扣减频繁冲突率高读多写少冲突概率低数据一致性事务串行强一致依赖重试可能更新失败实现复杂度SQL 加一行即可需要业务层循环重试对连接池影响持锁时间受事务长度影响不持锁连接占用短医药批号库存的典型特征是单品库存量不大但操作频繁销售出库和采购入库经常落在同一个批号上建议保留悲观锁但必须把事务控制在“查锁、更新、提交”这个最小范围内不要在事务里调用远程接口或做复杂计算。排查这套源码时重点看PurchaseService里有没有在Transactional方法中调用其他的 service 方法如果内部又开了新事务锁的持有时间会被拉长出现死锁的概率随之上升。4. 销售出库、FEFO 扣减与近效期预警4.1 FEFO 先效期先出与先进先出的区别普通商品出库遵循 FIFO先进先出药品出库必须遵循 FEFO即先效期先出。理由是药品的价值由效期决定同一药品的两个批次靠近期效期的必须先卖否则会批量过期。实现上就是在查询可用库存时用order by expiry_date asc替换order by create_time asc。出库扣减的核心语句是UPDATE drug_stock SET stock_qty stock_qty - #{qty} WHERE product_code #{productCode} AND batch_no #{batchNo} AND warehouse_code #{warehouseCode} AND stock_qty #{qty};注意最后一行stock_qty #{qty}这是防负数库存的数据库层兜底。如果更新影响行数为 0说明这一批次余额不足需要回到业务层再取下一个批次继续扣减或者直接报错。单独依赖 Java 代码里的if (stock.getStockQty() qty)是不够的两个并发事务可能同时通过判断产生超卖。4.2 近效期预警 SQL 与定时任务实现近效期药品被 GSP 列为重点管理对象这套源码里的预警逻辑是一天扫一次库把 90 天内到期的药品列出。查询 SQL 如下SELECT batch_no, product_code, expiry_date, DATEDIFF(expiry_date, CURDATE()) AS day_left FROM drug_batch WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND status 0 ORDER BY expiry_date ASC;这里BETWEEN的左右边界都是日期类型MySQL 会使用idx_product_expiry索引做范围扫描。如果换成DATEDIFF(expiry_date, CURDATE()) BETWEEN 0 AND 90因为列被函数包裹索引失效全表扫描在数据量大时会明显变慢。INTERVAL 90 DAY是 MySQL 的日期加减单位可以按需改成 30、60、180。status 0只取正常批次已经冻结或过期的批次不需要重复预警。定时任务用 Spring 的注解即可Scheduled(cron 0 30 2 * * ?) public void expiryWarningTask() { ListDrugBatch list batchMapper.selectNearExpiry(90); for (DrugBatch batch : list) { Integer remainQty stockMapper.sumQtyByBatch(batch.getBatchNo()); if (remainQty null || remainQty 0) { continue; } warnService.push(batch); } }代码里先查批次再有库存判断是为了减少预警报表里的假数据。比如某个批号已经卖完只是批次表里还留着记录这种情况没必要推送给采购。sumQtyByBatch汇总所有仓库的数量避免同一批药在多个仓库被重复预警。4.3 预警周期与 Cron 参数表预警级别剩余天数处理动作建议扫描频率黄色预警90181 天列表提示采购倾斜每天一次橙色预警3090 天弹窗提醒限制促销出库每天一次红色预警30 天内强制冻结禁止销售每 6 小时一次cron 0 30 2 * * ?的语义是秒为 0分为 30时为 2日期不限制月份不限制周不限制即每天凌晨 2:30 执行。选择凌晨是为了避开业务高峰减少和销售事务抢数据库连接。若需要每 6 小时执行一次可改为0 0 */6 * * ?但要注意drug_batch表的数据量如果超过十万行建议把“上次预警到期的 batch_no 集合”缓存到 Redis避免重复扫描同一批过期数据。5. ZIP 解压、MySQL 初始化与配置外置改造5.1 先校验 ZIP 完整性再解压拿到这套源码的 zip 包不要直接双击解压。先用命令校验包体是否完整zip -T medicine-system.zip unzip -t medicine-system.zipzip -T逐个文件计算校验和并输出 OK 或错误信息能发现文件是否损坏或是否中转过程被改过。网上流传的部分源码包存在所谓 zip 伪加密问题——压缩包目录区标记了加密位实际文件数据并未加密unzip会提示输入密码导致解压中断zip -T能识别这种结构异常。解压时中文文件名乱码多半是 Windows 压缩时用了 GBK 编码可以在 Linux 上指定编码unzip -O UTF-8 medicine-system.zip -d /opt/med-O UTF-8指定压缩包内文件名编码。CentOS 自带 unzip 版本过低时不支持-O参数可以先lsar medicine-system.zip查看编码再决定是否改用7z x解压。5.2 数据库字符集与 JDBC 连接串建库时不要用默认字符集直接指定CREATE DATABASE medicine DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;药品名称里有“阿司匹林肠溶片”这类中文没问题但供应商简称里可能出现生僻字或特殊符号utf8mb4 能完整存储。JDBC 连接串建议写成jdbc:mysql://localhost:3306/medicine?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiserverTimezoneAsia/Shanghai是必须要加的MySQL 8.x 驱动不指定时区会直接抛异常useSSLfalse避免本地开发环境因 SSL 证书报大量告警日志。导入 SQL 前先确认db.properties里的用户名密码和本机一致否则 Tomcat 启动后 MyBatis 初始化失败控制台只会报数据源创建异常。5.3 把数据库配置外置到 war 包之外源码包里配置文件默认在WEB-INF/classes/db.properties发布时需要重新打包才能改数据库地址。更实用的办法是让 Spring 从外部文件读取配置context:property-placeholder locationfile:/opt/med/conf/db.properties,classpath:db.properties ignore-resource-not-foundtrue/file:前缀指向外部绝对路径classpath:db.properties作为兜底ignore-resource-not-foundtrue让 Spring 忽略外部文件不存在的情况。这样部署时只需把db.properties放到/opt/med/conf下war 包本身不用改。改造后启动 Tomcat观察启动日志中是否出现Loading properties file from /opt/med/conf/db.properties再登录系统做一次采购入库用 SQL 核对SELECT product_code, batch_no, stock_qty FROM drug_stock WHERE product_code P001;能查到刚入库的数量说明配置外置没有破坏原有数据源初始化链路。之后发布新版只需替换 war 包数据库配置保留在外部避免每次打包前修改源码里的连接串。本文还有配套的精品资源点击获取