
简介本资源是一套基于SSM框架开发的超市进销存管理系统完整Java源码面向计算机、电子信息工程等专业本科生及毕业设计/课程设计学习者助力高分完成企业级Web应用实践项目。系统采用B/S架构与MVC模式涵盖商品管理、库存盘点、采购销售、员工权限等核心业务模块技术栈覆盖Java、Spring、SpringMVC、MyBatis、MySQL 5.7、Vue前端及Ajax交互适配JDK1.8、Maven3.6与Tomcat 8.0/9.0环境。压缩包共716个文件含184个Java后端逻辑类、130个Vue组件页面、162个SVG图标资源、55张业务图示及配套SQL建表脚本、配置文件与双启动批处理run.bat/build.bat结构清晰、模块解耦便于理解分层设计与前后端协同流程。资源大小20.25MB已有863人学习下载所有代码经实测可直接导入IDEA或Eclipse运行附带数据库初始化说明与常见部署问题提示是入门企业级Java Web开发的优质实战范例。1. 用 Java 搭建超市进销存系统不是写个 CRUD 就完事——它得扛住每日千笔出入库、实时库存校验、多角色权限隔离和月底报表导出很多刚学完 Java Web 的开发者看到“超市进销存管理系统”第一反应是不就是增删改查商品、供应商、员工做个 JSP 页面 Servlet MySQL 就能交差。但真实场景里一个县城连锁超市每天平均处理 800 笔销售单、120 笔采购入库、30 笔退货/调拨库存字段一旦出现负数或并发扣减错乱收银员扫码结账直接报错仓库盘点对不上账财务月底关账卡在“库存成本结转”环节。这不是功能堆砌问题而是数据一致性、事务边界、库存锁粒度、单据状态机和报表聚合逻辑的综合落地。本篇不讲 Spring Boot 脚手架生成器只聚焦「从零手写一个可部署、可验证、可扩展的 Java 进销存核心」——所有代码基于 JDK 17 Spring Boot 3.2 MyBatis-Plus 4.3数据库用 MySQL 8.0全程避开 XML 配置用注解LambdaQueryWrapper 实现业务逻辑重点拆解库存扣减的三种实现方式、单据编号自动生成规则、以及如何用 PageHelper POI 一键导出带合计行的 Excel 报表。2. 用 Spring Boot 3.2 MyBatis-Plus 4.3 搭建进销存基础骨架为什么选这组组合而非纯 JDBC 或 JPA2.1 选型依据轻量、可控、易调试且与超市业务节奏匹配超市进销存系统对 ORM 层的核心诉求不是“全自动映射”而是“能精准控制 SQL 执行时机、能显式管理事务边界、能快速定位慢查询”。JPA 的二级缓存和懒加载在高并发单据提交场景下极易引发 N1 查询或脏读纯 JDBC 虽快但模板代码冗余单据状态流转如采购单从“待审核”→“已入库”→“已完成”需手动维护大量 if-else 状态判断。MyBatis-Plus 在保留 SQL 可控性的前提下通过LambdaQueryWrapper实现类型安全的条件构造配合TableField(fill FieldFill.INSERT)自动填充创建时间用TableName(value stock_log, autoResultMap true)显式绑定日志表结构——这些特性让库存变动记录、单据审核流水等关键操作既可审计又易回溯。Spring Boot 3.2 的 Jakarta EE 9 基础和 GraalVM 原生镜像支持也让后续容器化部署如打包为 85MB 的 native image成为可能比传统 jar 包启动快 3 倍。2.2 项目结构分层按业务域而非技术层切分避免 Service 层膨胀src/main/java/com/supermarket/ ├── domain/ // 领域模型含 TableId、TableField 注解 │ ├── entity/ │ │ ├── Goods.java // 商品id, code(唯一编码), name, unit, price_buy, price_sell │ │ ├── Stock.java // 库存goods_id, warehouse_id, quantity, frozen_quantity(冻结库存) │ │ └── PurchaseOrder.java // 采购单code(格式PO20240521001), status, total_amount │ └── dto/ │ ├── PurchaseOrderDTO.java // 接收前端参数含 ListPurchaseItemDTO │ └── StockChangeLogDTO.java // 库存变动日志type(IN/OUT/ADJUST), before, after, operator ├── infrastructure/ // 基础设施含 DataSourceConfig、MyBatisPlusConfig │ └── config/ │ ├── DataSourceConfig.java // 多数据源配置主库报表库 │ └── MyBatisPlusConfig.java // 分页插件、SQL 性能分析插件 ├── application/ // 应用服务含事务边界和领域服务编排 │ ├── service/ │ │ ├── PurchaseOrderService.java // 采购单审核校验供应商、生成入库单、更新库存 │ │ └── StockService.java // 库存核心扣减、锁定、释放、预警检查 │ └── controller/ │ └── PurchaseOrderController.java // RESTful 接口POST /api/purchase-orders/approve └── interface/ // 对外接口含 Swagger 文档和统一返回体 └── vo/ └── ResultVO.java // {code: 200, data: {}, msg: 操作成功}提示domain.entity下不放 getter/setter全部用 LombokDataapplication.service中每个方法必须标注Transactional(rollbackFor Exception.class)且事务方法内禁止调用本类非事务方法防止代理失效。2.3 数据库建模关键点库存表必须分离可用量与冻结量单据编号需支持分布式生成-- 商品表code 为业务主键用于扫码枪识别 CREATE TABLE goods ( id bigint NOT NULL AUTO_INCREMENT, code varchar(32) NOT NULL COMMENT 商品编码全局唯一, name varchar(100) NOT NULL, unit varchar(10) NOT NULL DEFAULT 件 COMMENT 计量单位, price_buy decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 采购价, price_sell decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 零售价, PRIMARY KEY (id), UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存表quantity 为实际可用库存frozen_quantity 为被销售单占用的库存 CREATE TABLE stock ( id bigint NOT NULL AUTO_INCREMENT, goods_id bigint NOT NULL, warehouse_id bigint NOT NULL DEFAULT 1 COMMENT 仓库ID1为主仓, quantity int NOT NULL DEFAULT 0 COMMENT 可用库存, frozen_quantity int NOT NULL DEFAULT 0 COMMENT 冻结库存已被下单但未出库, PRIMARY KEY (id), UNIQUE KEY uk_goods_warehouse (goods_id,warehouse_id), KEY idx_goods_id (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采购单明细表关联采购单与商品记录本次采购数量和单价 CREATE TABLE purchase_order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, goods_id bigint NOT NULL, quantity int NOT NULL, price decimal(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;单据编号生成规则以采购单为例场景编号格式生成逻辑并发安全单机部署PO20240521001PO 8位日期 3位序列号当日从 001 开始synchronized块 Redis 计数器多节点部署PO202405210001PO 8位日期 4位序列号Redis INCR 位运算补零Redis 原子操作INCR// StockService.java 中调用 public String generatePurchaseOrderCode() { String datePart LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // 20240521 String key seq:purchase_order: datePart; Long seq redisTemplate.opsForValue().increment(key); return PO datePart String.format(%04d, seq); // PO202405210001 }注意Redis key 设置过期时间EXPIRE key 86400避免历史日期 key 持久占用内存若 Redis 不可用降级为本地AtomicLong 定时重置仅限测试环境。3. 库存扣减的三种实现方式从简单 update 到乐观锁再到分布式锁每种适用什么场景3.1 方式一基础 UPDATE WHERE 条件校验适用于低并发、非核心链路这是最直观的写法先查库存再判断是否足够最后执行扣减。但存在典型的“查-改”分离问题——两个线程同时查到quantity10都判断“够”然后都执行UPDATE stock SET quantity quantity - 5最终库存变成0而非5。// ❌ 错误示范存在超卖风险 public boolean deductStockWrong(Long goodsId, Integer quantity) { Stock stock stockMapper.selectById(goodsId); if (stock.getQuantity() quantity) { return false; // 库存不足 } stock.setQuantity(stock.getQuantity() - quantity); return stockMapper.updateById(stock) 0; }3.2 方式二UPDATE WHERE 原子条件推荐用于中等并发、单库场景将“查询”和“扣减”合并为一条 SQL利用数据库行锁保证原子性。MySQL InnoDB 在UPDATE ... WHERE时会对满足条件的行加 X 锁后续请求会阻塞等待。// ✅ 正确利用数据库 WHERE 条件做库存校验 public boolean deductStockByUpdateWhere(Long goodsId, Integer quantity) { LambdaQueryWrapperStock wrapper new LambdaQueryWrapper(); wrapper.eq(Stock::getGoodsId, goodsId) .gt(Stock::getQuantity, quantity - 1) // quantity quantity即库存足够 .setSql(quantity quantity - quantity); // 直接在 SQL 中计算 int updated stockMapper.update(null, wrapper); return updated 1; // 影响行数为 1 表示扣减成功 }执行的 SQL 等效于UPDATE stock SET quantity quantity - 5 WHERE goods_id 1001 AND quantity 5;提示gt(Stock::getQuantity, quantity - 1)是为了适配 MyBatis-Plus 的语法ge方法在 4.3 版本中需传入quantity而非quantity-1此处为语义清晰作此写法实际应使用ge(Stock::getQuantity, quantity)setSql是 MyBatis-Plus 4.3 新增的动态 SQL 设置方式避免拼接字符串。3.3 方式三乐观锁 版本号控制适用于高并发、需记录变更历史的场景当库存变动需同步写入日志表如stock_change_log且要求每次扣减都生成独立日志时乐观锁更合适。它不依赖数据库行锁而是通过版本号version字段检测冲突。// Stock.java 中添加 TableField(fill FieldFill.INSERT_UPDATE) private Integer version; // 初始为 0每次更新 1 // StockService.java public boolean deductStockWithVersion(Long goodsId, Integer quantity) { while (true) { Stock stock stockMapper.selectById(goodsId); if (stock.getQuantity() quantity) { return false; } // 构造更新条件必须是当前 version 值才允许更新 UpdateWrapperStock wrapper new UpdateWrapper(); wrapper.eq(id, goodsId) .eq(version, stock.getVersion()) .setSql(quantity quantity - quantity) .set(version, stock.getVersion() 1); int updated stockMapper.update(null, wrapper); if (updated 1) { // 写入日志 stockChangeLogMapper.insert(buildLog(goodsId, SALE_OUT, quantity, stock.getQuantity())); return true; } // version 不匹配说明其他线程已更新重试 } }三种方式对比表维度UPDATE WHERE乐观锁分布式锁Redis并发性能中行锁阻塞高无锁失败重试低网络开销锁竞争适用场景日常销售单扣减库存日志强一致要求跨服务调用如订单服务调用库存服务失败处理返回 false前端提示“库存不足”循环重试最多 3 次后抛异常获取锁超时如 5s返回“系统繁忙”代码复杂度★☆☆★★☆★★★是否需额外组件否否是Redis注意乐观锁重试次数必须限制如for (int i 0; i 3; i)避免无限循环耗尽 CPU分布式锁必须设置自动过期时间SET key value EX 10 NX防止死锁。4. 采购单审核流程的完整事务编排从单据状态机到库存联动拒绝“伪事务”4.1 状态机设计采购单生命周期必须有明确状态跃迁规则超市采购单不是简单的“草稿→完成”而是包含审核、入库、部分入库、作废等状态。状态变更必须满足前置条件且不可逆向跳转如“已入库”不能退回“待审核”。// PurchaseOrderStatus.java public enum PurchaseOrderStatus { DRAFT(0, 草稿), PENDING_APPROVAL(1, 待审核), APPROVED(2, 已审核), PARTIALLY_RECEIVED(3, 部分入库), FULLY_RECEIVED(4, 全部入库), CANCELLED(5, 已作废); private final int code; private final String desc; // 构造、getter 省略 } // PurchaseOrderService.java 中的状态校验 public void approvePurchaseOrder(Long orderId) { PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (!order.getStatus().equals(PurchaseOrderStatus.PENDING_APPROVAL.getCode())) { throw new BusinessException(采购单状态非法当前状态 order.getStatus()); } // 校验供应商是否存在、商品编码是否有效 checkSupplierAndGoods(order); // 执行库存扣减注意这里是采购入库所以是增加库存 for (PurchaseOrderItem item : order.getItems()) { stockService.increaseStock(item.getGoodsId(), item.getQuantity()); } // 更新采购单状态 order.setStatus(PurchaseOrderStatus.APPROVED.getCode()); order.setApprovedAt(LocalDateTime.now()); purchaseOrderMapper.updateById(order); }4.2 库存联动逻辑采购入库 ≠ 简单加库存需区分仓库和批次超市常有多个仓库总仓、门店仓且同一商品不同采购批次成本不同先进先出 FIFO。因此increaseStock方法必须接收warehouseId和batchNo批次号并写入stock_detail表记录成本。// StockService.java Transactional(rollbackFor Exception.class) public void increaseStock(Long goodsId, Integer quantity, Long warehouseId, String batchNo, BigDecimal costPrice) { // 1. 更新总库存 LambdaQueryWrapperStock wrapper new LambdaQueryWrapper(); wrapper.eq(Stock::getGoodsId, goodsId).eq(Stock::getWarehouseId, warehouseId); Stock stock stockMapper.selectOne(wrapper); if (stock null) { stock new Stock().setGoodsId(goodsId).setWarehouseId(warehouseId).setQuantity(quantity); stockMapper.insert(stock); } else { stock.setQuantity(stock.getQuantity() quantity); stockMapper.updateById(stock); } // 2. 写入库存明细用于成本核算 StockDetail detail new StockDetail() .setGoodsId(goodsId) .setWarehouseId(warehouseId) .setBatchNo(batchNo) .setQuantity(quantity) .setCostPrice(costPrice) .setInTime(LocalDateTime.now()); stockDetailMapper.insert(detail); }4.3 单据编号与库存变动日志的强关联确保每一笔库存变化可追溯到原始单据库存变动日志表stock_change_log必须存储source_type来源类型PURCHASE_IN/SALE_OUT/ADJUST和source_id来源单据 ID方便财务对账。CREATE TABLE stock_change_log ( id bigint NOT NULL AUTO_INCREMENT, goods_id bigint NOT NULL, warehouse_id bigint NOT NULL DEFAULT 1, source_type varchar(20) NOT NULL COMMENT 来源类型PURCHASE_IN, SALE_OUT, ADJUST, source_id bigint NOT NULL COMMENT 来源单据ID如采购单ID或销售单ID, change_quantity int NOT NULL COMMENT 变动数量正为入库负为出库, before_quantity int NOT NULL COMMENT 变动前库存, after_quantity int NOT NULL COMMENT 变动后库存, operator varchar(50) NOT NULL COMMENT 操作人, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_source (source_type,source_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;// StockService.java 中构建日志 private StockChangeLog buildLog(Long goodsId, String sourceType, Integer changeQuantity, Integer beforeQuantity) { StockChangeLog log new StockChangeLog(); log.setGoodsId(goodsId) .setSourceType(sourceType) .setSourceId(0L) // 此处需从调用方传入如 approvePurchaseOrder 中传入 order.getId() .setChangeQuantity(changeQuantity) .setBeforeQuantity(beforeQuantity) .setAfterQuantity(beforeQuantity changeQuantity) .setOperator(SecurityContextHolder.getContext().getAuthentication().getName()); return log; }提示source_id在采购单审核时传入order.getId()在销售单提交时传入saleOrder.getId()确保日志与单据一一对应source_type使用枚举定义避免字符串硬编码。5. 月底报表导出实战用 PageHelper POI 生成带合计行的 Excel解决财务对账刚需5.1 报表需求拆解财务要的不是“所有销售记录”而是按商品分类汇总的毛利报表超市财务每月需核对各商品类别销售额、销售成本、毛利额、毛利率。这意味着报表需聚合sale_order_item表关联goods表获取分类并计算sum(quantity * price_sell)和sum(quantity * cost_price)。// SaleReportService.java public InputStream exportMonthlySaleReport(LocalDate monthStart, LocalDate monthEnd) { // 1. 查询本月销售汇总含商品分类、销售额、销售成本 ListSaleReportVO reportList saleReportMapper.selectMonthlySummary( monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay() ); // 2. 计算合计行 SaleReportVO total new SaleReportVO(); total.setCategory(合计); total.setSalesAmount(reportList.stream().mapToDouble(SaleReportVO::getSalesAmount).sum()); total.setCostAmount(reportList.stream().mapToDouble(SaleReportVO::getCostAmount).sum()); total.setProfitAmount(total.getSalesAmount() - total.getCostAmount()); total.setProfitRate(total.getSalesAmount() 0 ? 0.0 : total.getProfitAmount() / total.getSalesAmount()); reportList.add(total); // 3. 用 POI 导出 Excel return ExcelExporter.export(销售毛利报表_ monthStart .xlsx, reportList, SaleReportVO.class); }5.2 ExcelExporter 工具类支持自动列宽、千分位、百分比格式和冻结首行// ExcelExporter.java public static T InputStream export(String fileName, ListT data, ClassT clazz) { try (Workbook workbook new XSSFWorkbook(); ByteArrayOutputStream out new ByteArrayOutputStream()) { Sheet sheet workbook.createSheet(销售报表); // 冻结首行 sheet.createFreezePane(0, 1, 0, 1); // 创建表头样式 Font headerFont workbook.createFont(); headerFont.setBold(true); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFont(headerFont); headerStyle.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 写入表头使用反射获取 ExcelColumn 注解 Field[] fields clazz.getDeclaredFields(); Row headerRow sheet.createRow(0); for (int i 0; i fields.length; i) { Field field fields[i]; ExcelColumn annotation field.getAnnotation(ExcelColumn.class); if (annotation ! null) { Cell cell headerRow.createCell(i); cell.setCellValue(annotation.value()); cell.setCellStyle(headerStyle); } } // 写入数据行 for (int i 0; i data.size(); i) { Row row sheet.createRow(i 1); T item data.get(i); for (int j 0; j fields.length; j) { Field field fields[j]; ExcelColumn annotation field.getAnnotation(ExcelColumn.class); if (annotation ! null) { field.setAccessible(true); Object value field.get(item); Cell cell row.createCell(j); if (value instanceof Number) { cell.setCellValue(((Number) value).doubleValue()); // 数值列设置格式金额列保留2位小数百分比列乘100 if (annotation.format().contains(0.00)) { CellStyle style workbook.createCellStyle(); DataFormat format workbook.createDataFormat(); style.setDataFormat(format.getFormat(annotation.format())); cell.setCellStyle(style); } } else { cell.setCellValue(value null ? : value.toString()); } } } } // 自动列宽 for (int i 0; i fields.length; i) { sheet.autoSizeColumn(i); } workbook.write(out); return new ByteArrayInputStream(out.toByteArray()); } catch (Exception e) { throw new RuntimeException(导出Excel失败, e); } }SaleReportVO 字段注解示例public class SaleReportVO { ExcelColumn(商品分类) private String category; ExcelColumn(销售额元) ExcelFormat(¥#,##0.00) private Double salesAmount; ExcelColumn(销售成本元) ExcelFormat(¥#,##0.00) private Double costAmount; ExcelColumn(毛利额元) ExcelFormat(¥#,##0.00) private Double profitAmount; ExcelColumn(毛利率) ExcelFormat(0.00%) private Double profitRate; }注意ExcelFormat是自定义注解用于指定单元格格式POI 的DataFormat需预先注册¥#,##0.00表示人民币千分位格式0.00%表示百分比autoSizeColumn在大数据量时可能卡顿生产环境建议限制列宽如sheet.setColumnWidth(i, 256 * 15)。5.3 Controller 层文件下载设置正确 Content-Type 和 Content-Disposition// ReportController.java GetMapping(/export/sale-report) public void exportSaleReport( RequestParam(start) String startStr, RequestParam(end) String endStr, HttpServletResponse response) throws IOException { LocalDate start LocalDate.parse(startStr); LocalDate end LocalDate.parse(endStr); InputStream inputStream saleReportService.exportMonthlySaleReport(start, end); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(销售毛利报表_ start .xlsx, StandardCharsets.UTF_8)); try (ServletOutputStream outputStream response.getOutputStream()) { inputStream.transferTo(outputStream); } }提示URLEncoder.encode必须指定 UTF-8否则中文文件名在 Chrome 下乱码transferTo比IOUtils.copy更高效直接利用零拷贝务必在try-with-resources中关闭流避免连接泄漏。本文还有配套的精品资源点击获取