医药进销存Java源码解析:批次管理、FEFO与事务控制
简介一套基于Java的医药进销存管理系统源码主要面向药店、医院药房和小型医药批发商也可作为计算机相关专业毕业设计或Java Web进阶学习的参考项目。系统围绕药品采购、销售、库存三大核心业务覆盖药品入库、出库、库存查询、销售记录跟踪等环节能够显著减少人工操作失误提升日常管理效率与数据准确性。压缩包内共包含944个文件整体大小约8.32MB。其中105个java文件承载了业务逻辑与服务接口324个html、152个css及128个js文件构成了前端展示与交互层另有6个sql数据库脚本、9个php文件以及若干xml、properties配置文件便于部署、调试与二次开发。整体目录结构划分明确可按模块快速定位源码。目前已有328人学习下载。这份源码提供完整的进销存功能实现包含前后端代码与数据库初始化脚本读者既能深入理解药品管理流程的设计思路也能直接改造用于自己的毕业项目是学习企业级Java开发、MVC分层架构与数据库交互的实用素材。1. 为什么医药进销存管理系统的 Java 源码值得拆开看「基于java的医药进销存管理系统源码.zip」常见于 java 课程设计多数人拿到手是改数据库密码、点运行、截图交差。但项目最有价值的部分不在界面而在「批次」两个字同一种药不同批号、不同有效期、不同进价数据库里不能合并成一个库存数字。Java 后端必须按批次做采购入库、按效期顺序出库、对临期药品预警还要保证任一步失败时整体回滚。这些逻辑正是 java 面试八股文里事务、行锁、条件更新的真实应用场景也是从 CRUD 走向业务后端的第一个门槛。下文按这套系统的标准工程结构展开先定模块边界和数据表再给采购入库与 FEFO 扣减的 Java 代码接着讲效期预警与报表统计最后一章给出拿到 zip 之后怎么验证批次扣减和并发正确性。适合正在做 java 课程设计的同学也适合想快速看懂医药进销存源码的后端。带着这份清单去读源码会比对着界面点一遍收获大得多。2. 医药进销存系统的模块边界与数据表设计2.1 采购、销售、库存三条业务主线的模块划分医药进销存功能再多业务主线也只有三条采购、销售、库存。常见做法是后端按 Spring Boot 的 controller-service-mapper 三层切把采购审核、入库、扣减、退货全部收敛到 service 层避免同一段库存逻辑在不同接口里复制三份。采购模块从采购单开始审核通过后生成入库单入库明细必须携带生产批号和有效期销售模块开单时实时扣减批次库存扣减成功才能继续开票收银库存模块管批次台账、效期预警和盘点盈亏。模块之间最容易出问题的是边界。有的课设工程把库存扣减写在 controller 里销售接口写一遍、退货接口又写一遍等加一个「前台零售」入口时就得改三处。正确的做法是单独抽一个 StockService 出来专门负责「从哪批扣、扣多少、写流水」所有出库方向的操作都调它。下面这张表列了这套系统最常见的五个模块和各自的边界模块核心实体关键操作容易踩的坑采购管理purchase_order / purchase_order_item下单、审核、入库入库漏写批次直接把总数加到药品上销售管理sale_order / sale_order_item开单、扣减、退货扣减不走 FEFO退货不回补原批次库存管理drug_batch / stock_flow台账、盘点、预警库存变动不留流水对不上账基础数据drug / supplier药品字典、供应商资质批号与效期唯一约束缺失系统管理sys_user / sys_role登录、权限、操作日志药房要求操作留痕日志表不能省2.2 批次表与效期字段的关联设计普通电商库存只关心某个商品还剩几个但医药进销存必须回答另一个问题剩的是哪一批什么时候过期。GSP 追溯要求每一盒药能沿着批号找到供应商和有效期近效期的要先卖过效期的即使有库存也不能出库。这决定了库存必须拆到批次粒度药品表只维护字典信息真正承载数量的是批次表 drug_batch。批次表的设计关键在于把「可变属性」挂到批次上而不是药品上。不同批次的进价可能不同同一药品 12 月进价和 3 月进价不一样毛利统计必须以实际出库那批的进价为准。sale_price 也放批次是因为零售价调整通常按新批次执行老批次卖完之前允许保持旧价。下面把 drug_batch 里几个容易理解错的字段列一下字段类型建议说明drug_idBIGINT与药品字典关联一个药品对应多行批次batch_noVARCHAR(64)生产批号同一药品不可重复expiry_dateDATE近效期先出的排序依据purchase_priceDECIMAL(10,2)该批次含税进价毛利计算依赖它initial_qty / remaining_qtyINT入库总量与当前剩余supplier_idBIGINT保留供应商做追溯2.3 建库 SQL 与关键索引设计批次表和流水表是整个工程的根基。批次表承载当前库存流水表记录每一次变动两张表配合才能回答「现在剩多少」和「为什么是这个数」。下面是这套源码里最值得抄的建表 SQL-- 药品批次表库存的最终落点一个药品对应多行批次 CREATE TABLE drug_batch ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_id BIGINT NOT NULL COMMENT 药品ID, batch_no VARCHAR(64) NOT NULL COMMENT 生产批号, supplier_id BIGINT DEFAULT NULL COMMENT 供应商ID, production_date DATE DEFAULT NULL COMMENT 生产日期, expiry_date DATE NOT NULL COMMENT 有效期至, purchase_price DECIMAL(10,2) NOT NULL COMMENT 含税进价, sale_price DECIMAL(10,2) NOT NULL COMMENT 零售价, initial_qty INT NOT NULL DEFAULT 0 COMMENT 入库数量, remaining_qty INT NOT NULL DEFAULT 0 COMMENT 剩余数量, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_batch (drug_id, batch_no, expiry_date), KEY idx_expiry (expiry_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次表;提示expiry_date 一律用 DATE不要用 DATETIME。用 DATETIME 存效期会让预警 SQL 的边界判断复杂化还可能把当天到期的批次漏算。联合唯一键 uk_batch 的作用是防止同一药品、同一批号、同一效期被重复入库。如果业务允许同一批号分批到货这个约束要挪到业务层去判断数据库只能退化成普通索引。idx_expiry 是给 FEFO 扣减和效期预警准备的查询条件里只剩 expiry_date 和 remaining_qty 时这个索引能让排序和范围扫描都走索引避免每次扣减都对全表排序。流水表记录每次库存变动的前后值查询和排错都靠它CREATE TABLE stock_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_id BIGINT NOT NULL COMMENT 药品ID, batch_id BIGINT NOT NULL COMMENT 批次ID, flow_type TINYINT NOT NULL COMMENT 1入库 2销售出库 3退货 4盘点调整, ref_no VARCHAR(32) NOT NULL COMMENT 关联业务单号, qty INT NOT NULL COMMENT 正数入、负数出, before_qty INT NOT NULL COMMENT 变动前剩余, after_qty INT NOT NULL COMMENT 变动后剩余, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_batch (batch_id), KEY idx_drug_time (drug_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;before_qty 和 after_qty 是排查并发问题的关键。两张表对不上时回看流水就能定位是哪个单子在什么时间把数量改错了。qty 用正负号表示方向比单独放一个「入库/出库」状态更容易对账统计时 SUM(qty) 就是净变动。流水只追加任何盘点调整都新增一条记录不要 UPDATE 历史流水否则对账口径就断了。3. 医药进销存采购入库与销售扣减的 Java 落地代码3.1 采购入库从采购单明细到批次记录采购入库的执行顺序是校验采购单状态、逐条读取明细、写入批次表、写库存流水整个过程必须在同一个事务里。批次表和流水表任何一张写失败库存账都会对不上。下面是入库服务的典型写法Service public class PurchaseInboundService { private final DrugBatchMapper batchMapper; private final StockFlowMapper flowMapper; Transactional(rollbackFor Exception.class) public void inbound(PurchaseOrderDTO order) { for (PurchaseItemDTO item : order.getItems()) { // 1. 校验同药同批号同效期是否已存在 DrugBatch exist batchMapper.findByDrugAndBatch( item.getDrugId(), item.getBatchNo(), item.getExpiryDate()); if (exist ! null) { throw new BusinessException(该批次已入库请走批次追加流程); } // 2. 写入新批次初始数量即入库数量 DrugBatch batch new DrugBatch(); batch.setDrugId(item.getDrugId()); batch.setBatchNo(item.getBatchNo()); batch.setExpiryDate(item.getExpiryDate()); batch.setPurchasePrice(item.getPurchasePrice()); batch.setSalePrice(item.getSalePrice()); batch.setInitialQty(item.getQty()); batch.setRemainingQty(item.getQty()); batchMapper.insert(batch); // 3. 写流水数量为正ref_no 关联采购单号 flowMapper.insert(StockFlow.of( item, batch.getId(), 1, 0, item.getQty())); } } }这段代码里有三个细节值得展开。Transactional(rollbackFor Exception.class) 指定任何异常都回滚因为入库时如果第 5 条明细失败前 4 条批次已经写入不指定 rollbackFor 会导致部分提交。批次冲突直接抛异常而不是覆盖数量是为了防止采购人员把同一批号录两次等到销售时才发现库存翻倍。流水表用 StockFlow.of 构建把 ref_no 关联到采购单号后续按单号就能反查一次入库影响了哪些批次。3.2 FEFO 扣减按有效期顺序出库的条件更新写法医药进销存的出库顺序必须遵循「近效期先出」也就是 FEFOFirst Expiry First Out。有的课程设计按入库时间先进先出这在医药场景是错的效期更早的批次一直不动最后就是临期损耗。扣减的思路先按效期排序取出可用批次再逐个消耗代码分成取批次和扣数量两步Component public class BatchPicker { private final DrugBatchMapper batchMapper; public ListPickedBatch pick(Long drugId, int needQty) { ListDrugBatch batches batchMapper.selectUsableByExpiry(drugId); ListPickedBatch result new ArrayList(); int remain needQty; for (DrugBatch b : batches) { if (remain 0) break; int take Math.min(remain, b.getRemainingQty()); result.add(new PickedBatch(b.getId(), take)); remain - take; } if (remain 0) { throw new InsufficientStockException(库存不足差 remain 件); } return result; } }selectUsableByExpiry 对应的 SQL 是 WHERE expiry_date CURDATE() AND remaining_qty 0 ORDER BY expiry_date ASC, id ASC。排序加上 id 是为了让同一天到期的多个批次也有稳定顺序否则两次扣减结果不一致退货时很难对账。取批次只是计算真正扣减必须在事务里用条件更新完成保证并发下不超卖Transactional public void saleOutbound(SaleOrderDTO sale) { ListPickedBatch picked batchPicker.pick(sale.getDrugId(), sale.getQty()); for (PickedBatch p : picked) { int rows batchMapper.deductIfEnough(p.getBatchId(), p.getQty()); if (rows 0) { throw new ConcurrentModifyException(批次 p.getBatchId() 库存已被修改请重试); } // 扣减后回查剩余量用于写流水的前后值 DrugBatch updated batchMapper.selectById(p.getBatchId()); flowMapper.insert(StockFlow.of(sale, updated, 2, -p.getQty())); } }deductIfEnough 的 SQL 是关键它把「数量是否够」和「扣减」合并成一条原子语句update iddeductIfEnough UPDATE drug_batch SET remaining_qty remaining_qty - #{qty} WHERE id #{id} AND remaining_qty #{qty} /update在 MySQL InnoDB 下这条 UPDATE 会对命中的行加排他锁第二条并发请求会等前一条提交后再执行因此不会出现两个请求都读到 remaining_qty5、各扣 3 件最终变成 -1 的情况。受影响行数 rows 为 0 表示剩余量不够抛异常让整个事务回滚之前已扣成功的批次也会一并还原。注意Transactional 只对 Spring 代理管理的方法生效。同一个类里 this 调用事务方法或者 catch 住异常后直接 return都会让事务形同虚设。3.3 销售退货回补与库存对账口径销售退货不能直接把数量加回药品表要回补到原批次上。回补时按销售明细里的 batch_id 找到原批次remaining_qty 加回去同时写一条 flow_type3 的流水。如果原批次已经过期不允许回补上架要转成报损单处理这是医药场景特有的分支普通进销存通常没这个判断。场景库存方向flow_type批次处理采购入库增加1新增批次记录销售出库减少2FEFO 扣减并写流水销售退货增加3回补原批次过期转报损盘点调整增减4按盘点差异调整对账时最常用的口径是「期初 入库 - 出库 期末」在批次表上表现为 sum(initial_qty) 与 sum(remaining_qty) 的差额应等于所有出库流水的数量之和。如果对不上优先查 flow_type2 和 flow_type4 的记录这两类最容易出现「扣了批次没写流水」或「写了流水没扣批次」的遗漏。4. 医药进销存效期预警、台账与统计报表的 Java 实现4.1 近效期预警的定时任务与阈值参数预警逻辑一句话讲完每天扫描批次表把有效期临近的批次捞出来通知到人。阈值一般分两级常见做法是 90 天以下算关注、30 天以下算严重具体天数放到配置里不要在代码里写死。实现用 Spring 的 Scheduled 就能扛住这个量级Component public class ExpiryAlertJob { Value(${pharma.alert.expiryThreshold:90}) private int thresholdDays; Scheduled(cron 0 30 2 * * ?) // 每天凌晨 2:30 执行 public void scan() { ListDrugBatch soon batchMapper.selectExpiringWithin(thresholdDays); MapInteger, ListDrugBatch grouped soon.stream() .collect(Collectors.groupingBy(b - remainingDays(b.getExpiryDate()))); grouped.forEach((days, list) - alertService.push( days 30 ? AlertLevel.SEVERE : AlertLevel.WARN, list)); } }selectExpiringWithin 的 SQL 用到期日范围过滤同时排除剩余量为 0 的批次SELECT * FROM drug_batch WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL #{days} DAY) AND remaining_qty 0 ORDER BY expiry_date ASC按剩余天数区分通知等级时可以配这样一张映射表后面接邮件、站内信或者企业微信机器人都方便剩余天数等级建议动作3190WARN邮件通知采购与店长≤30SEVERE弹窗提醒 禁止销售≥90正常不处理定时任务放在凌晨两点半是为了避开白天营业高峰。如果系统对时效要求更高也可以在每次销售扣减后顺带检查该批次剩余天数一命中阈值立刻提示不需要等次日扫描。BETWEEN 的边界要注意expiry_date 是 DATE 类型时用 CURDATE() 没问题如果当年误建成 DATETIME就必须改成 DATE_ADD(CURDATE(), INTERVAL 91 DAY)否则当天到期的批次会漏掉。4.2 进销存台账与毛利统计的 SQL 口径台账的本质是流水表按时间、药品维度聚合。医药系统和普通进销存的差别在于毛利率统计毛利必须用实际出库批次的进价来算不能用药品主数据里的最新进价。下面这条 SQL 是常见的实现SELECT DATE_FORMAT(f.created_at, %Y-%m-%d) AS biz_date, f.drug_id, SUM(CASE WHEN f.flow_type 2 AND f.qty 0 THEN -f.qty * b.sale_price ELSE 0 END) AS sale_amount, SUM(CASE WHEN f.flow_type 2 AND f.qty 0 THEN -f.qty * b.purchase_price ELSE 0 END) AS cost_amount FROM stock_flow f JOIN drug_batch b ON b.id f.batch_id WHERE f.created_at #{start} AND f.created_at #{end} GROUP BY biz_date, f.drug_id关联批次表取进价、销价而不是把价格写死在流水表里是因为价格字段如果冗余进流水改价时会不一致。但反过来如果批次进价在出库后被改过这个 SQL 算出的毛利就失真。折中的做法是流水表冗余 sale_price 和 purchase_price 两个快照字段批次表保留当前价对账以快照为准调价不影响历史毛利。4.3 Excel 报表导出的轻量封装报表导出用 EasyExcel 或 Apache POI 都行。小药房的数据量一般几万行用 EasyExcel 更省内存写法也更贴近业务。核心是把统计结果映射成带注解的 POJO然后一行代码写盘Data public class StockStatRow { ExcelProperty(日期) private String bizDate; ExcelProperty(药品名称) private String drugName; ExcelProperty(销售金额) private BigDecimal saleAmount; ExcelProperty(成本金额) private BigDecimal costAmount; } public void export(Long supplierId, LocalDate start, HttpServletResponse response) { ListStockStatRow rows statMapper.selectStat(supplierId, start); String fileName URLEncoder.encode(库存台账_ start, UTF-8); response.setHeader(Content-Disposition, attachment;filename fileName .xlsx); EasyExcel.write(response.getOutputStream(), StockStatRow.class) .sheet(台账) .doWrite(rows); }导出有两个细节。文件名必须 URLEncoder 编码否则中文文件名在部分浏览器里乱码。导出量超过几千行时分页查询组装 rows不要一次性把明细全查进内存。中小药房压力不大但如果要对接连锁药房几十家门店导出就要改成异步任务文件生成后放到下载目录再通知用户不能占着 HTTP 请求。5. 跑通医药进销存 zip 源码后的批次校验与并发防护实操拿到 zip 之后的验证不能停留在界面点单。批次扣减顺序、事务回滚、并发超卖三个点验证通过这套源码才算真正吃透。下面按执行顺序给出三条可抄的验证路径。5.1 用三个批次的最小数据集验证 FEFO构造一种药三个批次同一药品下 A 批效期 2025-12-31、B 批 2026-01-31、C 批 2026-02-28每批 10 件。跑一次销售出库 25 件预期扣减结果是批次效期扣减前预期扣减扣减后A2025-12-3110100B2026-01-3110100C2026-02-281055如果扣减后 C 批剩 0 而 A 批还有货说明排序条件写错了去查 SELECT ... ORDER BY 用的是不是 expiry_date 而不是 production_date。同一药品、同一效期存在多批次时还要确认排序第二关键字是 id 或入库时间否则每次出库选中的批次不稳定退货回补会出现「原批次已扣光」的怪问题。5.2 开 SQL 日志核对事务回滚是否真生效多数 zip 工程用的是 MyBatis把 mapper 日志级别调到 debug 就能看到每条 SQL 和事务边界。在 application.yml 里加另外解压前先确认 JDK 版本和 java 环境变量配置源码如果基于 JDK 8 写的用 JDK 17 跑可能遇到反射报错logging: level: com.example.pharmacy.mapper: debug构造一个第三行明细库存不足的销售单正常行为是前两条 UPDATE 出现在日志里随后抛出异常最后没有 COMMIT。去数据库查前两个批次的 remaining_qty如果已经变了说明事务注解没生效。最常见的原因是事务方法在同一个类里被 this 调用Spring 代理拦不到其次是开单服务里 catch 吞掉异常后直接 return事务只回滚运行时异常catch 后不重新抛出前面的扣减就跟着提交了。5.3 并发扣减的锁策略与对账验证FEFO 扣减里的条件 UPDATE 依赖 InnoDB 行锁同一批次并发下单时后到的请求会等前一个事务提交再执行。药房门店并发量不高这种隐式锁足够。如果源码里扣减前先 SELECT ... FOR UPDATE那是显式加锁并发更低但更直观两种都能防超卖。在连锁或医院场景做并发验证用两个线程同时各卖 8 件、批次库存 10 件期望一个成功、一个报库存不足而不是两边都成功。压测后跑一条对账 SQLSELECT SUM(qty) FROM stock_flow WHERE flow_type 2 得到的出库总数必须等于该药品所有批次 initial_qty 之和减去 remaining_qty 之和再回加退货部分。对账时记得排除 flow_type4 的盘点调整否则盘盈记录会把真实的出库差异掩盖掉。本文还有配套的精品资源点击获取