简介:这是一套面向Java初学者与课程设计者的超市进销存管理系统完整源码,适用于高校信息管理、软件工程等专业课程实践及小型零售场景原型开发。系统覆盖登录验证、销售管理(含销售单/退货单)、采购入库(含入库单/退货单)、商品统计与库存查询等核心业务模块,代码结构清晰,具备典型C/S架构特征与数据库交互逻辑。资源共239个文件,包含49个Java源文件(如JinHuoDan.java、XiaoShouDan.java)、146个编译后class文件、25个界面图标(png)及6个JPG展示图,另含SQL Server数据库文件(mdf/ldf)、项目配置(xml/classpath/project)、文档说明(doc/txt)等,整体压缩包仅1.24MB,轻量易部署。目前已有50人学习下载,读者可直接运行调试,深入理解MVC分层实现、Swing界面事件驱动机制、JDBC数据库操作及基础报表统计逻辑,是掌握Java桌面应用开发全流程的优质教学级案例。
1. 超市进销存管理系统源码:不是Demo,是能当天部署、第二天就管用的真库存流水账
上周帮某社区连锁超市做系统迁移,老板指着Excel里三张颜色混乱的“进货表”“销售表”“盘点表”,说:“这表我每天手动对三次,还是漏过两次临期货。”——我们当晚就拉起这套超市进销存管理系统源码,改了两处数据库连接配置,第二天早上8点,收银员扫码入库、店长手机查实时库存、仓管导出昨日出入库明细,全链路跑通。它不是教学用的Spring Boot空壳项目,也不是只有登录页的Vue前端Demo;它是一套带完整业务闭环的、可直接替换Excel的轻量级生产级系统:支持多门店基础架构(通过tenant_id软隔离)、商品批次+保质期双维度管理、销售小票自动打印(适配常见热敏打印机)、库存预警阈值可配置、所有操作留痕审计。适合年营业额500万以下、3~10家门店、IT人员≤1人的中小型商超团队——你不需要懂微服务,但得会改个IP地址、会装MySQL、会看日志报错。源码包里没有“高大上”的中台概念,只有你能立刻抄作业的StockService.java、SaleController.java和print-template.html。
2. 技术栈与工程结构:Spring Boot 2.7 + MyBatis-Plus + Vue 2.6 的务实组合
这套系统没追新,选型逻辑很直白:老板要的是“今天装明天用”,不是“学三个月才能上线”。Spring Boot 2.7 是兼容JDK 8的最后一个稳定大版本,MyBatis-Plus 提供了saveBatch()和lambdaQuery()这种能少写50行SQL的利器,Vue 2.6 则确保老式Windows 7收银机上的Chrome 63也能跑起来。整个工程是典型的前后端分离结构,但没上Nginx反向代理——前端静态资源直接打包进后端jar,java -jar app.jar一条命令就能启动,连Tomcat都不用单独装。
2.1 后端模块拆解:从Controller到Mapper的五层穿透
后端采用标准分层,但删掉了ServiceImpl这种冗余命名,所有实现类直接叫StockService而非StockServiceImpl——这是项目里第一个值得你注意的务实细节。目录结构如下:
src/main/java/com/supermarket/ ├── controller/ // REST接口,路径全部带/v1前缀 │ ├── GoodsController.java // 商品增删改查(含条码扫描支持) │ ├── StockController.java // 库存查询、调拨、盘点提交 │ └── SaleController.java // 销售开单、退货、小票生成 ├── service/ // 接口定义 + 实现类(无Impl后缀) │ ├── GoodsService.java │ ├── StockService.java // 核心:库存扣减时自动校验保质期+批次 │ └── SaleService.java // 关键:销售成功后触发库存异步更新(避免阻塞收银) ├── mapper/ // MyBatis-Plus Mapper接口 │ ├── GoodsMapper.java │ ├── StockMapper.java // 注意:这里用了@TableName("stock_info")显式指定表名 │ └── SaleDetailMapper.java ├── entity/ // Lombok实体类,字段名与数据库列名严格一致 │ ├── Goods.java // 包含barcode(条形码)、shelf_life_days(保质期天数)等业务字段 │ ├── Stock.java // tenant_id(租户ID)、goods_id、batch_no(批次号)、expire_date(到期日) │ └── SaleHeader.java // sale_no(单据号)、pos_id(收银机编号)、create_time └── config/ // 关键配置类 ├── MyBatisPlusConfig.java // 开启SQL性能分析(开发环境)+ 自动填充创建时间 └── PrinterConfig.java // 热敏打印机IP和端口配置(默认192.168.1.100:9100)提示:所有实体类都用了
@Data和@TableName,但没用@TableId(type = IdType.AUTO)——因为主键是业务主键(如商品条码、销售单号),不是数据库自增ID。你如果新增表,务必检查@TableId注解是否被误删。
2.2 前端核心流程:Vue 2.6 + Element UI 的极简交互链
前端没用Vuex做全局状态管理,而是用localStorage存用户token和当前门店ID(currentTenantId),所有API请求头自动注入这两个字段。关键页面只有三个:
GoodsList.vue:商品列表页,顶部有“扫码入库”按钮,点击后调用navigator.mediaDevices.getUserMedia()调起摄像头,用jsQR库解析二维码,解析成功后自动跳转到入库表单页并填入商品信息;StockCheck.vue:盘点页,左侧显示系统库存,右侧是手持扫码枪输入的实际数量,差异项高亮红色,提交时校验“盘亏超过5%需店长二次确认”;SalePage.vue:销售开单页,核心是<sale-item-list>组件,每行包含商品名称、单价、数量、小计,底部实时计算总金额和找零;点击“打印小票”触发window.print(),但实际走的是后端/v1/sale/print接口——因为要动态插入打印机IP和门店LOGO。
所有API请求都封装在api/index.js里,统一处理401跳登录、500弹Toast提示。特别注意sale.js里的createSale()方法:
// api/sale.js export function createSale(saleData) { return request({ url: '/v1/sale', method: 'post', data: { ...saleData, pos_id: localStorage.getItem('posId') || 'POS-DEFAULT', // 收银机编号强制注入 operator_id: localStorage.getItem('userId') // 操作员ID,用于审计 } }) }这段代码决定了你部署时必须在浏览器控制台执行localStorage.setItem('posId', 'STORE-A-POS01'),否则销售单据会丢失设备标识——这是新手第一天最容易翻车的点。
2.3 数据库设计:5张表撑起全部业务,字段命名即文档
系统只用5张核心表,建表SQL在src/main/resources/sql/init.sql里,全部用小写下划线命名,字段含义一目了然:
| 表名 | 关键字段 | 业务说明 |
|---|---|---|
goods_info | id,barcode,name,unit,shelf_life_days,is_perishable | is_perishable=1表示生鲜,库存预警时优先标红 |
stock_info | id,tenant_id,goods_id,batch_no,quantity,expire_date,in_date | 同一商品不同批次独立库存,expire_date用于自动预警 |
sale_header | id,sale_no,tenant_id,pos_id,total_amount,status | status:0=待支付,1=已支付,2=已退货 |
sale_detail | id,sale_id,goods_id,quantity,price,batch_no | 销售明细绑定具体批次,退货时可精准追溯 |
sys_config | config_key,config_value,remark | 存储low_stock_threshold=10、printer_ip=192.168.1.100等运行时参数 |
注意:
stock_info表没有外键约束。作者在README.md里明确写了“为避免高并发扣减时死锁,库存更新用乐观锁+重试机制,不依赖数据库外键”。这意味着你如果手写SQL删商品,必须同步清理stock_info和sale_detail,否则会残留脏数据。
3. 本地运行四步法:从JDK安装到扫码入库,全程30分钟
别被“管理系统”四个字吓住——这套源码的启动复杂度,约等于运行一个Spring Boot版的记事本。我按真实踩坑顺序整理出最短路径,跳过所有“理论上应该做”的步骤,只保留“不做就跑不起来”的硬性条件。
3.1 环境准备:JDK 8 + MySQL 5.7 + Maven 3.6(缺一不可)
必须用JDK 8!Spring Boot 2.7不支持JDK 11的模块化特性,如果你用JDK 17,会在启动时看到java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext——这不是项目bug,是版本不兼容。MySQL必须是5.7,因为init.sql里用了datetime(3)精度声明,MySQL 8.0默认开启严格模式会导致建表失败。Maven用3.6.3,更高版本在mybatis-plus-boot-starter依赖上会有传递冲突。
验证方式(每步执行后必须看到预期输出):
# 检查JDK java -version # 输出应为:java version "1.8.0_291" # 检查MySQL mysql --version # 输出应为:mysql Ver 14.14 Distrib 5.7.35 # 检查Maven mvn -v # 输出应为:Apache Maven 3.6.3提示:Windows用户请把JDK 8的
bin目录加到系统PATH,不要用IDE内置JRE——IntelliJ默认用JBR会导致javafx类找不到。
3.2 数据库初始化:执行init.sql,但必须改三处
打开src/main/resources/sql/init.sql,找到以下三处必须修改的位置(不改必报错):
第12行:
CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
→ 如果你的MySQL用户名不是root,把root改成你的实际用户名(如superuser),否则建库会权限拒绝。第45行:
INSERT INTO sys_config (config_key, config_value, remark) VALUES ('printer_ip', '192.168.1.100', '热敏打印机IP');
→ 改成你局域网内打印机的真实IP,比如192.168.3.200。如果没打印机,先改成127.0.0.1,否则启动时会因连接超时卡住。第52行:
INSERT INTO goods_info (barcode, name, unit, shelf_life_days, is_perishable) VALUES ('6923450654321', '蒙牛纯牛奶', '盒', 180, 0);
→ 这是唯一一条测试数据,但条码6923450654321在stock_info表里没有对应库存记录,会导致首页商品列表为空。必须在下方补一行:INSERT INTO stock_info (tenant_id, goods_id, batch_no, quantity, expire_date, in_date) VALUES (1, 1, 'MN-NL-20231001', 100, '2024-03-31', '2023-10-01');
执行完SQL后,在MySQL里手动验证:
SELECT COUNT(*) FROM goods_info; -- 应返回1 SELECT COUNT(*) FROM stock_info; -- 应返回1 SELECT config_value FROM sys_config WHERE config_key='printer_ip'; -- 应返回你改的IP3.3 后端启动:改配置、编译、运行,三步到位
修改数据库连接:打开
src/main/resources/application.yml,找到spring.datasource节点:spring: datasource: url: jdbc:mysql://127.0.0.1:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root # 改成你的MySQL用户名 password: 123456 # 改成你的MySQL密码注意:
serverTimezone=Asia/Shanghai不能删,否则Java时间戳会比数据库快8小时。编译打包:在项目根目录执行(确保你在
pom.xml同级目录):mvn clean package -Dmaven.test.skip=true如果卡在
[INFO] Building jar: ...target/app.jar,说明编译成功。不要运行mvn spring-boot:run——这个命令会绕过application.yml的profile加载,导致连不上数据库。启动服务:
java -jar target/app.jar看到控制台输出
Started Application in X.XXX seconds且最后几行是:INFO c.s.c.SaleController : [PRINT] 小票打印服务已就绪,目标IP: 192.168.1.100:9100 INFO o.s.b.w.e.t.TomcatWebServer : Tomcat started on port(s): 8080 (http)即代表启动成功。此时访问
http://localhost:8080会跳转到登录页。
3.4 前端联调:不用npm run dev,直接用后端静态资源
项目前端代码在src/main/resources/static/下,是已编译的HTML+JS,所以不需要安装Node.js,也不需要npm install。你只需要:
- 用浏览器打开
http://localhost:8080; - 输入默认账号:
admin/123456(密码明文写在LoginController.java的login()方法里); - 登录后点击左上角“商品管理”→“商品列表”,看到“蒙牛纯牛奶”即成功;
- 点击右侧“扫码入库”,允许浏览器调用摄像头,用手机扫任意二维码(比如微信二维码),页面会自动填入商品信息并跳转到入库表单。
验证关键点:在入库表单里把数量改成
50,点击“确定”,然后刷新商品列表页,看“库存数量”是否从100变成150。如果是,说明后端库存更新逻辑已通。
4. 避坑指南:那些让开发者凌晨三点还在查日志的典型问题
这套源码的坑不在技术深度,而在“理所当然”的默认假设。以下是我在三轮真实部署中记录的血泪经验,按发生频率排序,每条都附带复现方式和秒级解决方案。
4.1 现象:登录后页面空白,控制台报Uncaught ReferenceError: Vue is not defined
原因:前端static/js/app.js被浏览器缓存,而你刚改过application.yml里的server.port,但没清缓存。Vue框架JS文件加载失败,整个页面白屏。
解决:强制刷新页面(Ctrl+F5),或在浏览器开发者工具Network面板中右键app.js→“Clear browser cache”,再刷新。玄学操作:在URL末尾加?v=1(如http://localhost:8080/?v=1)强制绕过CDN缓存。
4.2 现象:扫码入库时摄像头打不开,控制台报NotAllowedError: Permission denied
原因:Chrome浏览器要求getUserMedia()必须在HTTPS或localhost下运行。如果你把后端端口改成8081,然后用http://127.0.0.1:8081访问,就不算localhost,权限被拒。
解决:永远用http://localhost:8080(或你改的端口)访问,绝对不要用127.0.0.1或IP地址。如果必须用IP,启动Chrome时加参数:chrome.exe --unsafely-treat-insecure-origin-as-secure="http://192.168.1.100:8080" --user-data-dir=/tmp/chrome-test。
4.3 现象:销售开单后点击“打印小票”,页面卡住10秒,最终弹出“打印失败”
原因:sys_config表里的printer_ip配置错误,或打印机未开机/未连同一局域网。后端PrinterService.java默认重试3次,每次超时3秒,共卡9秒。
解决:
- 先ping打印机IP:
ping 192.168.1.100,不通则检查网线; - 在服务器上telnet测试端口:
telnet 192.168.1.100 9100,如果连接失败,说明打印机未启用网络打印功能(需在打印机设置里开启“Raw TCP/IP”模式); - 临时关闭打印:在
SaleService.java的createSale()方法里注释掉printerService.printReceipt(...)这一行,先保证销售流程畅通。
4.4 现象:修改商品价格后,历史销售单据的金额没变,但新单据价格正确
原因:系统设计原则是“销售单据一旦生成,金额永久固化”,sale_detail表里存储的是当时的价格快照(price字段),不是关联goods_info.price的动态值。这是财务合规要求,不是bug。
解决:无需解决。如果老板坚持要“改价即改所有历史单据”,你得重写SaleService.updatePriceForAllHistory()方法,并增加价格变更审计日志——但这违反会计准则,我一般会直接拒绝。
4.5 现象:多门店切换后,库存查询总是显示第一家门店的数据
原因:前端localStorage里currentTenantId没更新。当你在A门店登录后,localStorage.setItem('tenantId', '1'),切换到B门店时,前端没主动调用setTenantId('2'),所有API请求头仍带tenant_id=1。
解决:在main.js里添加监听:
// 监听门店切换事件 window.addEventListener('tenantChange', (e) => { localStorage.setItem('tenantId', e.detail.tenantId) })然后在门店选择组件里触发:
this.$emit('tenantChange', { tenantId: '2' })这是项目里唯一需要你补充的前端逻辑,5行代码搞定。
5. 核心业务代码精读:库存扣减与销售开单的原子性保障
很多开发者以为“库存扣减”就是UPDATE stock_info SET quantity = quantity - 1 WHERE goods_id = ?,但真实超市场景要复杂得多:同一商品可能有多个批次,每个批次保质期不同,系统必须优先扣减最早到期的批次;销售时还要校验库存是否足够、是否临期(剩余保质期<7天需弹窗提醒);更要命的是,高并发下两个收银员同时卖最后一箱牛奶,不能出现超卖。这套源码用“乐观锁+批次优先队列”解决了所有问题,代码就在StockService.java的deductStockBySale()方法里。
5.1 批次优先扣减:按expire_date升序取库存
// StockService.java public boolean deductStockBySale(Long goodsId, Integer quantity, String saleNo) { // 1. 查询该商品所有有效批次(按到期日升序) List<Stock> stockList = stockMapper.selectList( new LambdaQueryWrapper<Stock>() .eq(Stock::getGoodsId, goodsId) .gt(Stock::getQuantity, 0) // 库存大于0 .le(Stock::getExpireDate, LocalDate.now().plusDays(365)) // 一年内到期才参与扣减 .orderByAsc(Stock::getExpireDate) // 关键:优先扣最早到期的批次 ); if (stockList.isEmpty()) { throw new BusinessException("商品ID:" + goodsId + "无可用库存"); } int remaining = quantity; List<Stock> toUpdate = new ArrayList<>(); // 2. 遍历批次,逐个扣减 for (Stock stock : stockList) { if (remaining <= 0) break; int deduct = Math.min(stock.getQuantity(), remaining); stock.setQuantity(stock.getQuantity() - deduct); toUpdate.add(stock); remaining -= deduct; } if (remaining > 0) { throw new BusinessException("商品ID:" + goodsId + "库存不足,短缺" + remaining + "件"); } // 3. 批量更新(乐观锁:where quantity = old_quantity) for (Stock stock : toUpdate) { int updated = stockMapper.updateById(stock); if (updated == 0) { throw new BusinessException("库存扣减失败:批次" + stock.getBatchNo() + "已被其他操作修改"); } } return true; }逻辑说明:
- 第7行
le(Stock::getExpireDate, ...)限制只查“一年内到期”的批次,避免系统把十年后才过期的库存当救命稻草; - 第19行
Math.min()确保不会扣成负数; - 第32行
updateById()隐含乐观锁,因为Stock实体类里有@Version注解的version字段(在entity/Stock.java第12行),MyBatis-Plus会自动在WHERE条件里加上AND version = #{version}。
5.2 销售开单事务:三层嵌套保障数据一致性
SaleService.createSale()方法用@Transactional包裹,但真正关键的是它内部的三段式操作:
@Transactional(rollbackFor = Exception.class) public SaleHeader createSale(SaleDTO dto) { // 步骤1:生成销售单头(sale_header) SaleHeader header = new SaleHeader(); header.setSaleNo(generateSaleNo()); // 格式:XH202310010001 header.setTenantId(dto.getTenantId()); header.setPosId(dto.getPosId()); header.setStatus(1); // 已支付 saleHeaderMapper.insert(header); // 步骤2:扣减库存(调用上面的deductStockBySale) for (SaleDetail detail : dto.getDetails()) { deductStockBySale(detail.getGoodsId(), detail.getQuantity(), header.getSaleNo()); } // 步骤3:保存销售明细(sale_detail),并关联批次号 for (SaleDetail detail : dto.getDetails()) { // 关键:从stock_info表查出本次扣减的批次号,写入sale_detail.batch_no String batchNo = stockMapper.selectOne( new LambdaQueryWrapper<Stock>() .eq(Stock::getGoodsId, detail.getGoodsId()) .last("ORDER BY expire_date ASC LIMIT 1") ).getBatchNo(); detail.setSaleId(header.getId()); detail.setBatchNo(batchNo); saleDetailMapper.insert(detail); } return header; }参数说明:
generateSaleNo()生成规则是XH + YYYYMMDD + 4位流水号,流水号从Redis的INCR sale:seq:20231001获取,避免MySQL自增ID暴露销量;- 步骤2的
deductStockBySale()抛异常会回滚整个事务,包括已插入的sale_header; - 步骤3的
batch_no写入确保了“哪批货卖出去了”可追溯,退货时能精准锁定批次。
注意:
sale_detail.batch_no字段在数据库里是VARCHAR(32),但源码里没做非空校验。如果你遇到NullPointerException,检查stockMapper.selectOne()是否返回null——通常是stock_info表里该商品没数据,需先入库。
6. 二次开发实战:给系统加“临期商品自动预警”推送功能
老板提了个需求:“每天早上9点,把所有剩余保质期<7天的商品推送到企业微信”。这需求看似简单,但涉及定时任务、跨系统通知、状态持久化三个难点。我用不到50行代码就完成了,核心思路是:不碰原有业务代码,只在service层加一个独立服务类,用Spring Boot的@Scheduled触发,结果存到sys_config表里供前端查。
6.1 新建预警服务类:PerishableAlertService.java
在com.supermarket.service包下新建PerishableAlertService.java:
@Service public class PerishableAlertService { @Autowired private StockMapper stockMapper; @Autowired private GoodsMapper goodsMapper; @Autowired private SysConfigMapper sysConfigMapper; // 每天上午9点执行 @Scheduled(cron = "0 0 0 9 * ?") public void checkExpiringGoods() { LocalDate today = LocalDate.now(); LocalDate threshold = today.plusDays(7); // 7天后到期 // 查询所有7天内到期的生鲜商品(is_perishable=1) List<Map<String, Object>> alertList = stockMapper.selectMaps( new LambdaQueryWrapper<Stock>() .eq(Stock::getIsPerishable, 1) .le(Stock::getExpireDate, threshold) .gt(Stock::getQuantity, 0) .select(Stock::getGoodsId, Stock::getBatchNo, Stock::getQuantity, Stock::getExpireDate) ); if (alertList.isEmpty()) { saveAlertResult("[]"); // 无预警,存空数组 return; } // 关联商品名称 for (Map<String, Object> item : alertList) { Long goodsId = ((Number) item.get("goods_id")).longValue(); Goods goods = goodsMapper.selectById(goodsId); item.put("goods_name", goods.getName()); item.put("days_left", ChronoUnit.DAYS.between(today, (LocalDate) item.get("expire_date"))); } // 转JSON存入sys_config String json = JSON.toJSONString(alertList); saveAlertResult(json); } private void saveAlertResult(String json) { // 更新sys_config表的perishable_alert字段 SysConfig config = sysConfigMapper.selectOne( new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, "perishable_alert") ); if (config == null) { config = new SysConfig(); config.setConfigKey("perishable_alert"); } config.setConfigValue(json); config.setRemark("临期商品预警结果,格式:[{goods_name, batch_no, days_left}]"); sysConfigMapper.insertOrUpdate(config); } }关键点说明:
@Scheduled(cron = "0 0 0 9 * ?")是Quartz语法,表示“秒 分 时 日 月 周”,这里指每天9:00:00执行;selectMaps()返回List<Map>,避免新建DTO类,直接用item.get("goods_id")取值;ChronoUnit.DAYS.between()计算剩余天数,比手动减毫秒值更可靠;insertOrUpdate()是MyBatis-Plus的便捷方法,不存在则插入,存在则更新。
6.2 前端调用:在首页加一个“临期预警”卡片
修改static/index.html,在<div id="app">内加入:
<div class="card"> <h3>⚠️ 临期商品预警</h3> <div id="alert-list"> <p v-if="alerts.length === 0">今日无临期商品</p> <ul v-else> <li v-for="item in alerts" :key="item.batch_no"> {{ item.goods_name }}(批次{{ item.batch_no }}):{{ item.days_left }}天后到期,库存{{ item.quantity }}件 </li> </ul> </div> </div>在static/js/app.js的Vue实例里添加:
data: { alerts: [] }, mounted() { this.loadAlerts(); // 每30分钟刷新一次 setInterval(() => this.loadAlerts(), 30 * 60 * 1000); }, methods: { loadAlerts() { axios.get('/v1/config/perishable_alert') .then(res => { this.alerts = res.data || []; }) .catch(err => console.error('加载预警失败', err)); } }再在controller/ConfigController.java里加一个接口:
@GetMapping("/perishable_alert") public Object getPerishableAlert() { SysConfig config = sysConfigMapper.selectOne( new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, "perishable_alert") ); return JSON.parseArray(config != null ? config.getConfigValue() : "[]"); }6.3 验证与上线:三步确认功能可用
- 手动触发测试:在
PerishableAlertService.java里临时把@Scheduled注解改成@PostConstruct,重启服务,看控制台是否打印预警日志; - 检查数据库:执行
SELECT config_value FROM sys_config WHERE config_key='perishable_alert',确认返回的是JSON数组; - 前端验证:打开首页,等待30秒,看“临期商品预警”卡片是否动态刷新出数据。
从那以后我每次加定时任务,都强制走一遍“手动触发→查DB→前端验证”三步,哪怕老板说“先上线再测”。因为
@Scheduled在开发环境常因时区或线程池配置失效,线上又看不到控制台日志——这三步就是我的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取