简介:这份基于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() { List<DrugBatch> 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 参数表
| 预警级别 | 剩余天数 | 处理动作 | 建议扫描频率 |
|---|---|---|---|
| 黄色预警 | 90~181 天 | 列表提示,采购倾斜 | 每天一次 |
| 橙色预警 | 30~90 天 | 弹窗提醒,限制促销出库 | 每天一次 |
| 红色预警 | 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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/ShanghaiserverTimezone=Asia/Shanghai是必须要加的,MySQL 8.x 驱动不指定时区会直接抛异常;useSSL=false避免本地开发环境因 SSL 证书报大量告警日志。导入 SQL 前先确认db.properties里的用户名密码和本机一致,否则 Tomcat 启动后 MyBatis 初始化失败,控制台只会报数据源创建异常。
5.3 把数据库配置外置到 war 包之外
源码包里配置文件默认在WEB-INF/classes/db.properties,发布时需要重新打包才能改数据库地址。更实用的办法是让 Spring 从外部文件读取配置:
<context:property-placeholder location="file:/opt/med/conf/db.properties,classpath:db.properties" ignore-resource-not-found="true"/>file:前缀指向外部绝对路径,classpath:db.properties作为兜底;ignore-resource-not-found="true"让 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 包,数据库配置保留在外部,避免每次打包前修改源码里的连接串。
本文还有配套的精品资源,点击获取