
简介本资源为基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 BaseMapperStockBatch { // 按近效期优先锁定可用批次用于销售扣减 Select(SELECT * FROM stock_batch WHERE medicine_id #{medicineId} AND quantity 0 ORDER BY expire_date ASC) ListStockBatch 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(); ListStockBatch 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.transactionDEBUG看有没有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 连接串没配时区或者配了serverTimezoneUTC而 MySQL 服务器用的是东八区Java 应用默认时区也是东八区三者不一致。解决连接串统一加serverTimezoneAsia/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 会暴露业务量而且分库分表后容易冲突。用「前缀 日期 序列」的格式可读性和扩展性都好很多。希望帮到你。本文还有配套的精品资源点击获取