☰
Java+MySQL医药销售管理系统:批号有效期库存与销售开单实战
2026/10/8 5:57:47 网站建设 项目流程

简介:本资源为基于Java与MySQL实现的医药销售管理系统课程设计项目,面向计算机相关专业学生及Java Web初学者,可用于课程设计、毕业设计参考或SSM/原生JSP练手。系统区分员工与经理两类角色:员工可管理会员与供应商、查询药品库存、录入采购、销售退货及盘点仓库;经理侧重人员与供应商维护,但无销售退货权限,供应商与顾客无使用权限,权限划分清晰。压缩包共26个文件,约1.06MB,以19个jsp页面为主体,辅以sql建库脚本、jar驱动包、xml配置、png界面截图与md说明文档,结构完整便于二次开发。目前已有264人学习下载。读者可获取完整数据库脚本、前后端页面源码与业务逻辑实现,快速理解医药进销存场景下的权限控制与数据流转,适合作为Java Web综合实践与排错参考。

1. 从一张 Excel 台账说起:医药销售管理系统到底要解决什么

很多做 JavaWeb 的朋友第一次接触「医药销售管理系统」这个题目,是在课程设计或者简历项目里。我见过太多版本:一个 Spring Boot 脚手架,几张 CRUD 表,跑起来能增删改查,但真拿去给药店或者小型医药流通公司用,三天就崩。问题不在技术栈,在于医药这个行业本身有它的特殊性——药品有批号、有效期、GSP 合规要求,销售出库要能追溯到具体批次,库存不能出现负库存,退货还得区分是否影响二次销售。这些约束如果不在数据库表结构和业务逻辑里体现,系统就只是个玩具。

这篇笔记讲的是基于 Java + MySQL 实现一套能真正落地的医药销售管理系统。核心链路是:药品基础信息管理 → 采购入库(带批号、生产日期、有效期)→ 库存批次管理 → 销售开单(自动扣减对应批次)→ 销售退货与库存回滚 → 报表统计。技术栈上,后端用 Spring Boot + MyBatis-Plus,数据库 MySQL 8.0,前端不展开,接口层用 RESTful 风格。适合两类人:一是正在做类似课设或毕设、想把它做得像样一点的学生;二是刚转 Java 后端、需要一个完整业务闭环练手的初级工程师。下面从表结构设计开始,一步步把关键实现和踩过的坑讲清楚。

2. 表结构设计:批号、有效期和库存怎么落到 MySQL 里

2.1 为什么药品不能只用一张库存表

普通商品库存表通常就是goods_id + quantity,但药品不行。同一款阿莫西林胶囊,可能有三批货:批号 A 生产日期 2024-03,有效期到 2026-03;批号 B 生产日期 2024-09,有效期到 2026-09。销售出库时必须按「先进先出、近效期先出」原则扣减,否则会出现旧批号积压过期、新批号先卖完的荒唐局面。所以库存必须拆成「药品基础表 + 库存批次表」两层。

基础表存药品的通用属性:名称、规格、厂家、批准文号、是否处方药、单位、零售价、进价。批次表存每一批的物理库存:批号、生产日期、有效期、当前数量、所属仓库。销售明细表则要记录这笔销售扣的是哪个批次,这样才能做到正向可追溯、逆向可退货。

2.2 核心建表 SQL 与字段说明

下面这段 SQL 是整套系统的地基,我把它精简到最必要的字段,实际项目里可以按需扩展。

-- 药品基础信息表 CREATE TABLE `medicine` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `medicine_code` VARCHAR(32) NOT NULL COMMENT '药品编码,唯一', `medicine_name` VARCHAR(128) NOT NULL COMMENT '药品名称', `spec` VARCHAR(64) DEFAULT NULL COMMENT '规格,如 0.25g*24粒', `manufacturer` VARCHAR(128) DEFAULT NULL COMMENT '生产厂家', `approval_no` VARCHAR(64) DEFAULT NULL COMMENT '国药准字批准文号', `is_prescription` TINYINT DEFAULT 0 COMMENT '是否处方药 0否 1是', `unit` VARCHAR(16) DEFAULT '盒' COMMENT '单位', `purchase_price` DECIMAL(10,2) DEFAULT 0.00 COMMENT '默认进价', `sale_price` DECIMAL(10,2) DEFAULT 0.00 COMMENT '默认零售价', `status` TINYINT DEFAULT 1 COMMENT '状态 1启用 0停用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_medicine_code` (`medicine_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品基础信息'; -- 库存批次表,核心中的核心 CREATE TABLE `stock_batch` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `medicine_id` BIGINT NOT NULL COMMENT '关联药品', `batch_no` VARCHAR(64) NOT NULL COMMENT '生产批号', `production_date` DATE DEFAULT NULL COMMENT '生产日期', `expire_date` DATE NOT NULL COMMENT '有效期至', `quantity` INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', `warehouse_id` BIGINT DEFAULT 1 COMMENT '仓库ID', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_medicine_expire` (`medicine_id`, `expire_date`), UNIQUE KEY `uk_medicine_batch` (`medicine_id`, `batch_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存批次'; -- 销售主表 CREATE TABLE `sale_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '销售单号', `customer_name` VARCHAR(128) DEFAULT NULL COMMENT '客户名称', `total_amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '销售总额', `operator_id` BIGINT DEFAULT NULL COMMENT '开单人', `sale_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` TINYINT DEFAULT 1 COMMENT '1正常 2已退货', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售单'; -- 销售明细,记录扣减的批次 CREATE TABLE `sale_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `medicine_id` BIGINT NOT NULL, `batch_id` BIGINT NOT NULL COMMENT '扣减的库存批次', `quantity` INT NOT NULL COMMENT '销售数量', `price` DECIMAL(10,2) NOT NULL COMMENT '销售单价', `amount` DECIMAL(10,2) NOT NULL COMMENT '小计', PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细';

字段设计上有几个点值得单独说。stock_batch上的联合索引idx_medicine_expire是为了按近效期排序查库存,销售扣减时ORDER BY expire_date ASC能直接走索引。uk_medicine_batch保证同一药品同一批号只有一条记录,避免重复入库产生脏数据。金额字段统一用DECIMAL(10,2),不要用FLOAT或DOUBLE,否则累加会出现 0.30000000000000004 这种经典问题,对账时能让人崩溃。

2.3 用 MyBatis-Plus 生成实体与 Mapper

表建好后,实体类可以用 MyBatis-Plus 的代码生成器一键生成,也可以手写。手写其实更快,因为字段不多。这里给出StockBatch实体和对应的 Mapper 接口,重点看@TableName和自定义查询方法。

@Data @TableName("stock_batch") public class StockBatch { @TableId(type = IdType.AUTO) private Long id; private Long medicineId; private String batchNo; private LocalDate productionDate; private LocalDate expireDate; private Integer quantity; private Long warehouseId; private LocalDateTime createTime; } @Mapper public interface StockBatchMapper extends BaseMapper<StockBatch> { // 按近效期优先锁定可用批次,用于销售扣减 @Select("SELECT * FROM stock_batch " + "WHERE medicine_id = #{medicineId} AND quantity > 0 " + "ORDER BY expire_date ASC") List<StockBatch> selectAvailableBatches(@Param("medicineId") Long medicineId); // 扣减库存,带数量校验,防止负库存 @Update("UPDATE stock_batch SET quantity = quantity - #{qty} " + "WHERE id = #{batchId} AND quantity >= #{qty}") int deductStock(@Param("batchId") Long batchId, @Param("qty") Integer qty); }

selectAvailableBatches用ORDER BY expire_date ASC实现近效期先出,这是 GSP 里的基本要求。deductStock的WHERE quantity >= #{qty}是关键,它把库存校验放在 SQL 层,利用数据库行锁保证并发下不会扣成负数。如果返回影响行数为 0,说明库存不足,业务层直接抛异常回滚。这种「条件更新」比先查再改的写法安全得多,后者在并发场景下必然翻车。

3. 销售开单的核心逻辑:批次扣减与事务边界

3.1 一次销售请求要经过哪些步骤

销售开单不是简单插一条销售单就完事。完整流程是:接收前端传来的药品列表(药品 ID + 数量)→ 开启事务 → 生成销售单号 → 插入销售主表 → 对每个药品按近效期顺序查可用批次 → 逐个批次扣减库存 → 记录销售明细(含批次 ID)→ 汇总金额回写主表 → 提交事务。任何一步失败,整个事务回滚,不能出现「主表插了、库存没扣」或者「扣了库存、明细没记」的情况。

这里的事务边界必须覆盖「扣库存」和「写明细」两个操作。我见过有人把扣库存放在循环外单独提交,结果并发时库存扣了但明细插入失败,对账时库存和销售记录对不上,查起来非常痛苦。

3.2 销售服务层代码与参数说明

下面这段是销售开单的核心服务方法,用@Transactional控制事务,循环处理每个药品的批次扣减。

@Service public class SaleService { @Autowired private SaleOrderMapper orderMapper; @Autowired private SaleItemMapper itemMapper; @Autowired private StockBatchMapper batchMapper; @Transactional(rollbackFor = Exception.class) public String createSale(SaleRequest request) { // 1. 生成单号,格式 S + yyyyMMdd + 6位序列 String orderNo = "S" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%06d", System.currentTimeMillis() % 1000000); SaleOrder order = new SaleOrder(); order.setOrderNo(orderNo); order.setCustomerName(request.getCustomerName()); order.setTotalAmount(BigDecimal.ZERO); orderMapper.insert(order); BigDecimal total = BigDecimal.ZERO; // 2. 逐个药品处理 for (SaleItemRequest itemReq : request.getItems()) { int needQty = itemReq.getQuantity(); List<StockBatch> batches = batchMapper.selectAvailableBatches(itemReq.getMedicineId()); for (StockBatch batch : batches) { if (needQty <= 0) break; int deduct = Math.min(batch.getQuantity(), needQty); int rows = batchMapper.deductStock(batch.getId(), deduct); if (rows == 0) { throw new BizException("库存扣减失败,批次:" + batch.getBatchNo()); } // 记录明细 SaleItem item = new SaleItem(); item.setOrderId(order.getId()); item.setMedicineId(itemReq.getMedicineId()); item.setBatchId(batch.getId()); item.setQuantity(deduct); item.setPrice(itemReq.getPrice()); item.setAmount(itemReq.getPrice().multiply(BigDecimal.valueOf(deduct))); itemMapper.insert(item); total = total.add(item.getAmount()); needQty -= deduct; } // 3. 所有批次扣完仍不够,说明库存不足 if (needQty > 0) { throw new BizException("药品库存不足,药品ID:" + itemReq.getMedicineId()); } } // 4. 回写总金额 order.setTotalAmount(total); orderMapper.updateById(order); return orderNo; } }

几个参数和逻辑点需要说明。@Transactional(rollbackFor = Exception.class)里的rollbackFor必须写,因为 Spring 默认只对RuntimeException回滚,如果抛的是受检异常,事务不会回滚,这是血泪教训。Math.min(batch.getQuantity(), needQty)决定当前批次扣多少,如果批次库存够就全扣,不够就扣完再找下一批。deductStock返回 0 时抛异常,触发整个事务回滚,保证不会出现部分扣减。最后needQty > 0的判断是兜底,防止所有批次加起来都不够的情况。

3.3 并发下的库存安全:条件更新比锁更可靠

有人会问,两个销售员同时卖同一批药怎么办。上面的deductStock用的是UPDATE ... WHERE quantity >= #{qty},MySQL 在执行这条语句时会对匹配的行加排他锁,第二个事务会等待第一个提交后再执行。如果第一个扣完库存变成 0,第二个的quantity >= qty条件不成立,影响行数为 0,直接抛异常。这比在 Java 层用synchronized或者SELECT ... FOR UPDATE再判断要简洁得多,而且不依赖应用层的锁粒度。

需要注意的是,selectAvailableBatches查询和deductStock更新之间没有加锁,理论上存在「查到批次时还有库存,扣减时已被别人扣完」的情况。但因为deductStock有条件校验,这种情况会返回 0 并抛异常,不会造成负库存。如果业务上希望减少这种重试,可以在查询时用FOR UPDATE,但会降低并发吞吐,一般小规模系统没必要。

4. 避坑与排查:医药销售系统里最容易翻车的 5 个点

4.1 有效期字段用了 VARCHAR 导致排序错乱

现象:按近效期排序查库存时,2025-01-01 排在了 2024-12-31 后面,或者出现 2024-1-1 和 2024-01-01 混排。

原因:expire_date字段建表时用了VARCHAR,字符串排序按字符逐位比较,月份和日期没补零就会乱。更隐蔽的是,有人存成2024/1/1和2024-01-01两种格式,排序结果完全不可预期。

解决:有效期、生产日期一律用DATE类型,Java 侧用LocalDate接收。如果历史数据已经是字符串,先用STR_TO_DATE转换再改字段类型。查询时不要用ORDER BY expire_date对字符串排序,改成ORDER BY STR_TO_DATE(expire_date, '%Y-%m-%d')只是临时补救,根治还是改类型。

4.2 销售退货时直接加库存,没回滚批次

现象:客户退了一盒药,系统把库存加回去了,但加到了错误的批次上,或者加到了已经过期的批次上。

原因:退货逻辑只写了UPDATE stock_batch SET quantity = quantity + #{qty} WHERE medicine_id = #{id},没有指定批次。MySQL 会更新匹配的第一条记录,可能是任意批次。

解决:退货必须根据原销售明细里的batch_id精确回滚。先查sale_item找到这笔销售扣的是哪个批次,再对该批次执行quantity = quantity + #{qty}。同时要判断该批次是否已过期,如果过期则不允许回滚到可销售库存,应转入「退货待处理」状态,由人工决定是否报废。这个逻辑在 GSP 里是有明确要求的,不能图省事。

4.3 事务方法内部调用导致 @Transactional 失效

现象:销售开单时库存扣减失败抛了异常,但销售主表已经插入了一条记录,数据不一致。

原因:createSale方法被同一个类里的另一个方法直接调用(this.createSale()),Spring AOP 代理不生效,@Transactional形同虚设。

解决:要么把事务方法抽到独立的 Service 类里,通过注入调用;要么在同类中注入自身代理(@Autowired private SaleService self;)再用self.createSale()调用。更推荐第一种,结构清晰。排查时可以打开 Spring 的事务日志,在application.yml里加logging.level.org.springframework.transaction=DEBUG,看有没有Creating new transaction输出。

4.4 金额计算用 double 导致对账差几分钱

现象:销售单总金额和明细累加差 0.01 元,或者退货时金额算出来是 19.999999999998。

原因:Java 里用double或float做金额运算,二进制浮点数无法精确表示十进制小数。

解决:所有金额字段用BigDecimal,数据库用DECIMAL。BigDecimal的multiply、add都要用,不要混用double。比较金额时用compareTo而不是equals,因为equals会比较精度(1.0和1.00不相等)。除法必须指定舍入模式,比如divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP)。

4.5 MySQL 8.0 驱动时区配置错误导致时间差 8 小时

现象:销售单的sale_time存进去是 2025-01-15 10:00:00,查出来变成 2025-01-15 02:00:00,差了 8 小时。

原因:JDBC 连接串没配时区,或者配了serverTimezone=UTC,而 MySQL 服务器用的是东八区,Java 应用默认时区也是东八区,三者不一致。

解决:连接串统一加serverTimezone=Asia/Shanghai,同时确认 MySQL 的time_zone参数。可以用SELECT @@global.time_zone, @@session.time_zone;查看。如果 MySQL 返回SYSTEM,说明跟随操作系统时区,一般没问题。Java 侧LocalDateTime不涉及时区转换,但Date会,所以实体类尽量用LocalDateTime。

5. 进阶技巧:用存储过程做库存盘点与效期预警

5.1 为什么把盘点逻辑放进 MySQL

库存盘点和效期预警是医药销售系统里两个高频但计算量不大的任务。放在 Java 层做当然可以,但每次都要把全量批次数据拉到内存再遍历,数据量大了之后 GC 压力明显。我一般会把这类「集合运算 + 条件筛选」的逻辑写成存储过程,让 MySQL 在服务端算完只返回结果集,网络传输和内存占用都小很多。而且存储过程可以配合 MySQL 的事件调度器定时执行,不需要额外引入 Quartz 或 XXL-Job。

5.2 效期预警存储过程与调用方式

下面这个存储过程扫描所有库存大于 0 的批次,把 90 天内到期的标记为预警,30 天内到期的标记为紧急。

DELIMITER $$ CREATE PROCEDURE `check_expire_warning`(IN `warn_days` INT, IN `urgent_days` INT) BEGIN -- 先清空上次的预警结果 TRUNCATE TABLE expire_warning; -- 插入 90 天内到期的批次 INSERT INTO expire_warning(batch_id, medicine_id, batch_no, expire_date, quantity, warn_level) SELECT b.id, b.medicine_id, b.batch_no, b.expire_date, b.quantity, CASE WHEN DATEDIFF(b.expire_date, CURDATE()) <= urgent_days THEN 'URGENT' ELSE 'WARN' END AS warn_level FROM stock_batch b WHERE b.quantity > 0 AND DATEDIFF(b.expire_date, CURDATE()) <= warn_days AND b.expire_date >= CURDATE(); END$$ DELIMITER ; -- 调用:90 天预警,30 天紧急 CALL check_expire_warning(90, 30);

DATEDIFF(b.expire_date, CURDATE())算出距离今天还有多少天,<= warn_days筛出预警范围内的批次。CASE WHEN根据紧急天数分两级。expire_warning表需要提前建好,字段和 SELECT 列表对应。这个存储过程可以每天凌晨通过 MySQL 事件调度器执行一次,也可以用 Spring 的@Scheduled定时调用。

5.3 盘点差异表的生成与核对

盘点场景下,实际库存和系统库存的差异需要单独记录。我通常建一张stock_check表,存盘点单号和盘点时间,再建stock_check_item存每个批次的账面数和实盘数。差异计算用一条 SQL 就能出结果:

SELECT i.batch_id, b.batch_no, i.book_qty, i.actual_qty, (i.actual_qty - i.book_qty) AS diff_qty FROM stock_check_item i JOIN stock_batch b ON i.batch_id = b.id WHERE i.check_id = #{checkId} AND i.actual_qty <> i.book_qty;

diff_qty为正表示盘盈,为负表示盘亏。盘盈盘亏的审批和调账是另一个流程,但差异表必须先出,否则盘点就是走过场。这里有个经验:盘点期间要锁定库存,不允许销售出库,否则账面数一直在变,盘点结果永远对不上。可以在stock_batch上加一个is_locked字段,盘点开始时置 1,销售扣减时校验该字段。

5.4 一个我坚持了很久的习惯

做这类业务系统,我养成了一个习惯:每张涉及库存和金额的表,建表时都加create_time和update_time,并且update_time用ON UPDATE CURRENT_TIMESTAMP。看起来是小事,但排查问题时,能一眼看出这条记录最后一次变动是什么时候,比翻日志快得多。另外,销售单号、入库单号这类业务流水号,不要用自增 ID 直接拼,自增 ID 会暴露业务量,而且分库分表后容易冲突。用「前缀 + 日期 + 序列」的格式,可读性和扩展性都好很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询