简介:这是一套面向高校计算机专业课程设计与Java Web入门实践的医药销售管理系统源码,采用Java结合MySQL数据库开发,适合需要完成课程设计、毕业设计或想练习JSP+Servlet+数据库综合应用的学习者。系统按角色划分权限:员工可管理会员与供应商、查询药品库存、录入采购与销售记录、处理退货及盘点仓库;经理在此基础上增加员工与供应商的增删权限,但不参与销售退货业务,供应商与顾客无使用权限,业务逻辑贴近真实医药流通场景。压缩包共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_id | approval_no 是批准文号,追溯要用 |
drug_batch | 药品批次 | id, drug_id, batch_no, production_date, expiry_date, purchase_price, stock_qty | 批号 + 效期是核心,库存挂在这一层 |
supplier | 供应商 | id, supplier_name, contact, phone, license_no | license_no 经营许可证号 |
customer | 客户 | id, customer_name, type, phone, address | type 区分药店、诊所、个人 |
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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone=Asia/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 id="selectForUpdate" resultType="com.example.entity.DrugBatch"> SELECT * FROM drug_batch WHERE id = #{batchId} FOR UPDATE </select>FOR 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 List<TraceVO> traceByBatch(@PathVariable String batchNo) { return orderMapper.selectTraceByBatchNo(batchNo); }<select id="selectTraceByBatchNo" resultType="com.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())算天数,阈值判断用<=还是<想清楚;数据源强制serverTimezone=Asia/Shanghai。
4.4 中文药品名乱码,导出 Excel 全是问号
现象:页面显示正常,导出 Excel 或写日志时中文变问号。原因:数据库连接没指定字符集,或者导出时没设响应编码。解决:JDBC URL 加characterEncoding=utf8,数据库和表统一utf8mb4,导出接口设置response.setContentType("application/vnd.ms-excel;charset=utf-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() { List<DrugBatch> list = batchMapper.selectExpiringSoon(90); for (DrugBatch b : list) { // 写预警记录或推送,这里简化为日志 log.warn("批次 {} 将于 {} 到期,剩余库存 {}", b.getBatchNo(), b.getExpiryDate(), b.getStockQty()); } } }<select id="selectExpiringSoon" resultType="com.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 </select>cron = "0 0 2 * * ?"是每天凌晨两点,避开业务高峰。#{days}传 90 表示三个月内。DATEDIFF返回的是天数差,BETWEEN 0 AND 90把已过期的排除掉,已过期的批次应该走另一个下架流程,不要混在预警里。
5.3 一个我坚持的习惯
做这类业务系统,我养成了一个习惯:任何涉及库存和金额的写操作,先在纸上把「事务边界」和「并发场景」画一遍再写代码。医药销售管理系统看着是 CRUD,真正难的是数据一致性,库存扣错一次,追溯和对账就要花十倍时间补。上面这些表结构、事务写法和排查清单,是我踩过坑之后沉淀下来的最小可用版本,你可以直接拿去改。希望帮到你。
本文还有配套的精品资源,点击获取