
简介这是一套面向高校计算机专业课程设计与Java Web入门实践的医药销售管理系统源码采用Java结合MySQL数据库开发适合需要完成课程设计、毕业设计或想练习JSPServlet数据库综合应用的学习者。系统按角色划分权限员工可管理会员与供应商、查询药品库存、录入采购与销售记录、处理退货及盘点仓库经理在此基础上增加员工与供应商的增删权限但不参与销售退货业务供应商与顾客无使用权限业务逻辑贴近真实医药流通场景。压缩包共26个文件约1.06MB以19个jsp页面为主体配合1个sql建库脚本、1个xml配置、1个jar驱动及说明文档覆盖登录、会员管理、药品采购、销售退货、财务统计等模块目录结构清晰。目前已有264人学习下载可帮助读者快速理解分层设计与权限控制思路对照源码完成数据库建表、功能扩展与调试排错。1. 从一张药品出库单说起医药销售管理系统到底在管什么很多做 JavaWeb 毕设或接私活的朋友第一次听到「医药销售管理系统」会下意识觉得这就是个带增删改查的进销存。真上手才发现医药行业和普通商品零售差得远同一盒药有批号、有效期、批准文号卖出去要能追溯到具体批次库存要按效期先进先出退货还得区分质量问题和非质量问题。这些约束决定了它不是把商品换成药品名那么简单而是要在数据模型层面就把「批号 效期 库存流水」设计进去。这篇笔记围绕「基于 Java MySQL 实现的医药销售管理系统」展开讲清楚一套能跑起来、能演示、也能经得起答辩追问的实现路径。技术栈走最常见的 JavaWeb 组合Spring Boot 做后端、MyBatis 或 MyBatis-Plus 做持久层、MySQL 存业务数据、前端用 Thymeleaf 或 Vue 都行。适合正在做课程设计、毕业设计或者想拿一个完整业务系统练手的 Java 开发工程师。下面从表结构怎么设计开始一路讲到库存扣减、批号追溯和上线前必须排查的坑。2. 数据库先立住医药销售管理系统的表结构与批号效期建模医药销售管理系统的成败八成在数据库设计阶段就定了。业务代码写得再花哨如果表结构里没有批号和效期这两个维度后面做追溯、做先进先出、做效期预警全部要推倒重来。这一章先把核心表定下来再讲几个容易埋雷的字段设计。2.1 核心表清单与字段取舍一套能支撑销售、采购、库存、追溯的系统最少需要下面这些表。我一般会按「主数据 单据 流水」三层来分主数据变动少单据记录一次业务动作流水是库存的每一次增减明细。表名作用关键字段说明drug药品主数据id, drug_code, drug_name, spec, unit, approval_no, manufacturer, category_idapproval_no 是批准文号追溯要用drug_batch药品批次id, drug_id, batch_no, production_date, expiry_date, purchase_price, stock_qty批号 效期是核心库存挂在这一层supplier供应商id, supplier_name, contact, phone, license_nolicense_no 经营许可证号customer客户id, customer_name, type, phone, addresstype 区分药店、诊所、个人purchase_order/purchase_item采购单主表/明细order_no, supplier_id, total_amount / batch_id, qty, price明细落到批次sale_order/sale_item销售单主表/明细order_no, customer_id, total_amount / batch_id, qty, price明细落到批次才能追溯stock_flow库存流水id, batch_id, change_type, change_qty, before_qty, after_qty, ref_order_no, create_time每次增减都记一条出问题靠它查user/role用户与角色id, username, password, role_id权限控制用这里最关键的一个决定是库存不挂在drug上而是挂在drug_batch上。同一盒阿莫西林不同批号、不同效期进价可能都不一样库存必须分开算。销售明细也必须指向batch_id而不是drug_id否则出了质量问题你根本不知道卖出去的是哪一批。2.2 建表 SQL 与索引设计下面给出批次表和库存流水表的建表语句这两张表是整套系统的地基。-- 药品批次表库存和效期的载体 CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL COMMENT 药品ID, batch_no VARCHAR(64) NOT NULL COMMENT 生产批号, production_date DATE NOT NULL COMMENT 生产日期, expiry_date DATE NOT NULL COMMENT 有效期至, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 进货单价, stock_qty INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_drug_batch (drug_id, batch_no), KEY idx_expiry (expiry_date), KEY idx_drug (drug_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次表; -- 库存流水表每一次库存变动的黑匣子 CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id BIGINT NOT NULL COMMENT 批次ID, change_type VARCHAR(16) NOT NULL COMMENT IN采购/OUT销售/RETURN退货/ADJUST盘点, change_qty INT NOT NULL COMMENT 变动数量正数入库负数出库, before_qty INT NOT NULL COMMENT 变动前库存, after_qty INT NOT NULL COMMENT 变动后库存, ref_order_no VARCHAR(64) DEFAULT NULL COMMENT 关联单据号, operator VARCHAR(32) DEFAULT NULL COMMENT 操作人, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_batch (batch_id), KEY idx_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;uk_drug_batch这个唯一索引是防止同一药品同一批号被重复录入很多同学漏掉它结果盘点时发现同一个批号有两条记录库存对不上。idx_expiry是给效期预警查询用的系统要定期扫「三个月内到期」的批次没索引数据量一大就慢。stock_flow里的before_qty和after_qty看着冗余但排查库存对不上时这两个字段能让你一眼看出是哪一步算错了属于典型的后悔药设计。2.3 效期与先进先出的字段约定医药行业出库默认遵循先进先出FIFO也就是先卖效期近的批次。实现上有两种做法一种是在销售时按expiry_date升序查可用批次一种是在批次表加一个priority字段人工干预。我一般用前者简单可靠。-- 查询某药品可用批次按效期升序排除已过期 SELECT id, batch_no, expiry_date, stock_qty FROM drug_batch WHERE drug_id #{drugId} AND stock_qty 0 AND expiry_date CURDATE() ORDER BY expiry_date ASC;expiry_date CURDATE()这行不能省过期药品绝不能再销售这是医药系统的合规底线。ORDER BY expiry_date ASC保证先出近效期的货。参数#{drugId}由前端选择药品后传入。如果业务要求允许销售临期但未过期的药品可以在 SQL 里加一个expiry_date DATE_ADD(CURDATE(), INTERVAL 30 DAY)之类的阈值把临期批次单独提示而不是直接屏蔽。3. 后端接口怎么落地Spring Boot 下的销售与库存扣减表结构定好之后进入后端实现。这一章讲清楚销售下单这条主链路重点在库存扣减的并发安全和事务边界这两块是医药销售管理系统最容易翻车的地方。3.1 项目分层与依赖配置后端用 Spring Boot 3.x 起步持久层选 MyBatis-Plus省掉大量单表 CRUD 的样板代码。pom.xml里核心依赖如下。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies分层按controller - service - mapper - entity走DTO 和 VO 单独放别图省事直接用 entity 接前端参数字段一多就容易出越权赋值的问题。application.yml里数据源配置注意时区MySQL 8 默认serverTimezone不写会报时区错误。spring: datasource: url: jdbc:mysql://localhost:3306/pharma_sale?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.DriverserverTimezoneAsia/Shanghai必须显式指定否则create_time存进去可能差 8 小时效期计算跟着一起错。3.2 销售下单与库存扣减的事务写法销售下单要同时做三件事写销售单主表、写销售明细、扣批次库存并记流水。这三步必须在一个事务里任何一步失败全部回滚。Service public class SaleOrderService { Autowired private DrugBatchMapper batchMapper; Autowired private StockFlowMapper flowMapper; Autowired private SaleOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void createSaleOrder(SaleOrderDTO dto) { // 1. 写销售单主表 SaleOrder order new SaleOrder(); order.setOrderNo(generateOrderNo()); order.setCustomerId(dto.getCustomerId()); order.setTotalAmount(dto.getTotalAmount()); orderMapper.insert(order); // 2. 逐条明细扣库存 for (SaleItemDTO item : dto.getItems()) { // 悲观锁锁定批次行防止并发超卖 DrugBatch batch batchMapper.selectForUpdate(item.getBatchId()); if (batch null || batch.getStockQty() item.getQty()) { throw new BizException(批次库存不足: item.getBatchId()); } int before batch.getStockQty(); int after before - item.getQty(); // 更新库存 batchMapper.updateStock(item.getBatchId(), after); // 3. 记库存流水 StockFlow flow new StockFlow(); flow.setBatchId(item.getBatchId()); flow.setChangeType(OUT); flow.setChangeQty(-item.getQty()); flow.setBeforeQty(before); flow.setAfterQty(after); flow.setRefOrderNo(order.getOrderNo()); flowMapper.insert(flow); } } }Transactional(rollbackFor Exception.class)里的rollbackFor不能省默认只回滚运行时异常业务里抛的自定义异常如果是受检异常就不会回滚这是血泪经验。selectForUpdate对应 SQL 是SELECT ... FOR UPDATE在事务内锁住批次行保证同一批次不会被两个订单同时扣减导致超卖。select idselectForUpdate resultTypecom.example.entity.DrugBatch SELECT * FROM drug_batch WHERE id #{batchId} FOR UPDATE /selectFOR UPDATE必须在事务里才有意义脱离事务它就是普通查询。参数#{batchId}是明细里选定的批次。这里有个性能取舍悲观锁会串行化同一批次的扣减如果秒杀级别的并发可以改成UPDATE drug_batch SET stock_qty stock_qty - #{qty} WHERE id #{batchId} AND stock_qty #{qty}靠影响行数判断是否成功把锁交给数据库行锁减少一次查询。3.3 批号追溯查询接口出了质量问题要能根据销售单号反查卖的是哪个批号或者根据批号反查卖给了哪些客户。这就是追溯查询医药系统的刚需。GetMapping(/trace/batch/{batchNo}) public ListTraceVO traceByBatch(PathVariable String batchNo) { return orderMapper.selectTraceByBatchNo(batchNo); }select idselectTraceByBatchNo resultTypecom.example.vo.TraceVO SELECT so.order_no, so.create_time, c.customer_name, si.qty FROM sale_item si JOIN sale_order so ON si.order_id so.id JOIN drug_batch db ON si.batch_id db.id JOIN customer c ON so.customer_id c.id WHERE db.batch_no #{batchNo} ORDER BY so.create_time DESC /select这条 SQL 把销售明细、销售单、批次、客户串起来输入批号就能列出所有流向。#{batchNo}是路径参数。实际项目里追溯查询往往还要支持按客户反查、按时间段过滤可以在 VO 里加更多字段但核心 JOIN 关系就是这几张表。注意sale_item上要有batch_id索引否则数据量上来后这个查询会全表扫。4. 避坑与排查医药销售管理系统上线前必须过的五道坎功能跑通不等于能用下面这五条是我在类似项目里真实踩过的按「现象 → 原因 → 解决」写清楚照着排查能省不少时间。4.1 库存对不上流水和批次余额不一致现象盘点时发现drug_batch.stock_qty和stock_flow累加出来的数对不上差几条。原因库存更新和流水插入不在同一事务或者某次手工改库没记流水。解决所有库存变动必须走统一的服务方法方法内Transactional包住更新和流水插入再加一个定时对账任务每天扫一遍批次余额和流水累计不一致就告警。4.2 并发下单导致超卖库存扣成负数现象压测时同一批次被两个订单同时扣最后库存变成负数。原因先查库存再更新两步之间没有锁。解决用SELECT ... FOR UPDATE悲观锁或者用带条件的UPDATE ... WHERE stock_qty #{qty}靠影响行数判断两种都能防超卖前者简单后者性能好。4.3 效期计算差一天临期预警误报现象效期预警把还有 31 天到期的批次也报出来了。原因expiry_date是 DATE 类型CURDATE()比较时边界处理不对或者时区导致日期偏移。解决统一用DATEDIFF(expiry_date, CURDATE())算天数阈值判断用还是想清楚数据源强制serverTimezoneAsia/Shanghai。4.4 中文药品名乱码导出 Excel 全是问号现象页面显示正常导出 Excel 或写日志时中文变问号。原因数据库连接没指定字符集或者导出时没设响应编码。解决JDBC URL 加characterEncodingutf8数据库和表统一utf8mb4导出接口设置response.setContentType(application/vnd.ms-excel;charsetutf-8)。4.5 删除药品主数据导致历史单据查不到现象删了一个停用的药品结果历史销售单详情页报错。原因用了物理删除外键关联断了。解决主数据一律逻辑删除加is_deleted字段查询默认过滤历史单据关联的药品即使停用也要能显示名称所以删除只改状态不删行。5. 进阶技巧用存储过程做月度销售统计与效期预警基础功能做完答辩或交付时如果能拿出一个像样的统计模块说服力会强很多。这里讲两个我常用的进阶点用 MySQL 存储过程做月度销售汇总以及用定时任务做效期预警。5.1 存储过程做月度销售统计报表查询如果每次都在 Java 里拼复杂 SQL维护起来很痛苦。把月度汇总逻辑封进存储过程调用简单也方便 DBA 直接跑。DELIMITER $$ CREATE PROCEDURE sp_monthly_sales(IN p_year INT, IN p_month INT) BEGIN SELECT d.drug_name, d.spec, SUM(si.qty) AS total_qty, SUM(si.qty * si.price) AS total_amount FROM sale_item si JOIN sale_order so ON si.order_id so.id JOIN drug_batch db ON si.batch_id db.id JOIN drug d ON db.drug_id d.id WHERE YEAR(so.create_time) p_year AND MONTH(so.create_time) p_month GROUP BY d.id, d.drug_name, d.spec ORDER BY total_amount DESC; END$$ DELIMITER ;调用就是CALL sp_monthly_sales(2025, 3);。参数p_year、p_month由前端传入。注意GROUP BY里把d.id也带上避免同名药品被合并。存储过程适合固定报表如果维度经常变还是老老实实在 Java 里用 MyBatis 动态 SQL。5.2 效期预警定时任务效期预警用 Spring 的Scheduled每天跑一次扫出三个月内到期的批次写进预警表或直接发通知。Component public class ExpiryWarningTask { Autowired private DrugBatchMapper batchMapper; // 每天凌晨2点执行 Scheduled(cron 0 0 2 * * ?) public void checkExpiry() { ListDrugBatch list batchMapper.selectExpiringSoon(90); for (DrugBatch b : list) { // 写预警记录或推送这里简化为日志 log.warn(批次 {} 将于 {} 到期剩余库存 {}, b.getBatchNo(), b.getExpiryDate(), b.getStockQty()); } } }select idselectExpiringSoon resultTypecom.example.entity.DrugBatch SELECT * FROM drug_batch WHERE stock_qty 0 AND DATEDIFF(expiry_date, CURDATE()) BETWEEN 0 AND #{days} ORDER BY expiry_date ASC /selectcron 0 0 2 * * ?是每天凌晨两点避开业务高峰。#{days}传 90 表示三个月内。DATEDIFF返回的是天数差BETWEEN 0 AND 90把已过期的排除掉已过期的批次应该走另一个下架流程不要混在预警里。5.3 一个我坚持的习惯做这类业务系统我养成了一个习惯任何涉及库存和金额的写操作先在纸上把「事务边界」和「并发场景」画一遍再写代码。医药销售管理系统看着是 CRUD真正难的是数据一致性库存扣错一次追溯和对账就要花十倍时间补。上面这些表结构、事务写法和排查清单是我踩过坑之后沉淀下来的最小可用版本你可以直接拿去改。希望帮到你。本文还有配套的精品资源点击获取