简介:面向小型超市收银场景的毕业设计项目源码,适合计算机相关专业学生和初入商业软件开发的学习者,用于理解收银业务从商品录入到交易结算的基础实现。内容围绕添加商品、商品销售、商品查询与计算器小工具展开,并涉及库存管理、会员管理、报表分析、支付接口、退货退款和权限管理等扩展方向,可据此梳理数据库设计、前端界面与后端逻辑的协作关系。压缩包约3.3MB,文件总数显示为0、类型明细暂未提供,实际文件清单以上游为准;据描述可能包含源代码、数据库配置和用户手册等,便于对照学习系统结构与业务模块划分。已有3120人学习或下载,可作为小型收银系统课程设计与毕业设计的参考样本,帮助读者建立从需求到功能落地的整体认识。
1. 一笔 38 块 5 的结账背后:这套超市收银系统源码到底在管什么
早高峰,收银员用扫码枪连着扫了六件商品,屏幕跳出应收 38.5 元,会员卡一刷自动抹掉 5 毛,顾客递上一张 50 元,钱箱弹开找零 11.5 元,小票机"滋啦"一声吐出小票,整个过程不到三秒。这三秒里,一套合格的超市收银系统源码其实干完了六件事:解析条码、匹配商品与价格、算会员优惠、扣库存、落订单和明细、驱动小票打印。任何一环掉链子,收银台前立刻排起长队。
这份源码就是围绕这条链路搭起来的:收银台负责快,后台负责准,数据库负责账实一致。适合三类人——想做毕业设计但不想从零画表的同学、接社区超市私活需要快速交付的开发者、以及自家小店想换掉老式单机收银软件的老板。它不追求大而全的 ERP,重点是把"收钱、减货、记账"三件事做扎实,剩下的会员、促销、报表都是在上面加壳。
2. 工程结构与技术选型:前后端分离 + 收银端如何拆模块
一套超市收银系统源码能不能二次开发,先看它怎么分模块。分成"收银端 + 管理后台 + 服务端"三块的项目,改起来最省心——收银端出问题不影响后台改商品,后台挂掉收银端还能靠本地缓存顶一会儿。这份工程就是这种结构,前端一套 Vue 工程同时承载收银台和管理后台,靠路由和权限区分,打包时按需拆成两个入口。
2.1 技术栈的组合与选型理由
后端用 Spring Boot 2.5 + MyBatis-Plus + MySQL 8.0,缓存和防重复提交用 Redis。这套组合在国内中小型管理系统里几乎是默认答案:MyBatis-Plus 的LambdaQueryWrapper能把 80% 的 CRUD 写成一两行,遇到复杂报表 SQL 又能直接写 XML,不用被 ORM 绑架。MySQL 8.0 的窗口函数在日结、月度毛利这类统计里省事不少。
前端 Vue 2 + Element UI + Axios,收银台页面单独做了大字号、大按钮、高对比度的布局,因为收银员很多是四五十岁的阿姨,界面精细不重要,能一眼看清金额才重要。小票打印走浏览器打印通道,window.print()配合一套@media print的 CSS,比后端调 ESC/POS 指令省心,也方便换 58mm 和 80mm 两种纸宽。
| 层次 | 技术选型 | 承担职责 |
|---|---|---|
| 收银端 | Vue 2 + Element UI | 扫码、购物车、结算、打印 |
| 管理后台 | Vue 2 + Element UI + ECharts | 商品、会员、库存、报表 |
| 服务端 | Spring Boot 2.5 + MyBatis-Plus | 业务逻辑、事务、权限 |
| 存储 | MySQL 8.0 + Redis | 主数据、订单、锁与缓存 |
| 部署 | Nginx + jar 包 | 静态资源与接口反向代理 |
选型里有一个容易忽略的点:没有上微服务。单店或三五家连锁的收银场景,QPS 峰值也就几十,拆微服务只会让运维成本翻倍。常见做法是把订单、商品、会员放在同一个工程的不同 package 下,将来真要拆再按 package 边界切,成本可控。
2.2 目录结构逐个看:从 pos-server 到 pos-web
拿到源码第一件事是看目录,目录结构能告诉你作者有没有认真分层。这套工程的目录大致长这样:
supermarket-pos/ ├── pos-server/ # 后端服务 │ ├── src/main/java/com/pos/ │ │ ├── controller/ # 接口层,只做参数校验和返回封装 │ │ ├── service/ # 业务层,事务边界都在这里 │ │ │ └── impl/ │ │ ├── mapper/ # MyBatis-Plus Mapper 接口 │ │ ├── entity/ # 数据库实体 │ │ ├── dto/ # 入参出参对象 │ │ ├── common/ # 统一返回、全局异常、工具类 │ │ └── config/ # 跨域、拦截器、Redis 配置 │ └── src/main/resources/ │ ├── mapper/ # 复杂 SQL 的 XML │ ├── db/pos.sql # 建表 + 初始数据 │ └── application.yml # 数据源、Redis、打印配置 ├── pos-web/ # 前端工程(收银端 + 后台) │ ├── src/ │ │ ├── views/cashier/ # 收银台主界面 │ │ ├── views/goods/ # 商品与分类维护 │ │ ├── views/order/ # 订单查询与退货 │ │ ├── views/report/ # 日结、毛利报表 │ │ ├── api/ # 接口封装 │ │ └── router/ # 路由与权限守卫 │ └── vue.config.js # 开发代理配置 └── README.mdservice层是整个工程的重心,事务注解、库存扣减、订单号生成都压在这里;controller保持得很薄,只做@Valid校验和Result包装,这个习惯很关键——如果业务逻辑漏到 controller,将来做接口复用会非常难受。dto和entity严格分开,避免把数据库字段直接吐给前端,比如商品的进价字段就不该出现在收银端的响应里。
resources/mapper下放的是报表类 SQL,像"按日统计各支付方式金额""按商品分类统计毛利"这类带GROUP BY的查询,用 XML 写比链式调用清楚得多。db/pos.sql是整套系统的起点,建库、建表、插入默认管理员和测试商品都在里面,导入完就能直接跑。
2.3 数据表设计:订单、明细、库存流水三张表的配合
收银系统的数据表能不能撑住,看三张表的关系:订单主表sale_order、订单明细sale_order_item、库存流水stock_record。主表存一笔交易的头信息——订单号、收银员、会员、应收、实收、找零、支付方式、营业日期;明细存每一行商品——商品 ID、名称快照、单价快照、数量、小计。
这里有个设计细节值得说:明细表必须存商品名称和单价的快照,不能只存商品 ID 去关联查。因为商品改名、调价之后,历史订单打印出来的小票必须还是当时的价格,否则退货对账时全是糊涂账。这是踩过坑的人才会加上的字段。
stock_record是库存流水,每次入库、销售扣减、退货回补都记一条,字段包括商品 ID、变动数量(正负)、变动类型、关联单号、操作人。有了它,商品表上的stock字段就只是一个"当前余额"的冗余,真实账目永远可以靠流水重算出来。库存对不上时,把流水按商品汇总一遍,和goods.stock一比,差在哪一笔一目了然。这套设计不复杂,但少有人一开始就想明白。
3. 核心链路拆解:从扫码枪输入到小票打印机出纸
收银台这条链路是整个项目最值得读的部分,它把前端输入、后端事务、硬件输出串成了一条线。读懂这条线,剩下的商品管理、会员管理都是它的变体。
3.1 扫码枪输入:一次 keydown 如何变成购物车条目
扫码枪在操作系统眼里就是一个键盘,扫一次条码等于快速敲入一串字符再敲一个回车。所以前端不需要什么特殊 SDK,只要保证输入框始终聚焦,监听回车即可。麻烦在于扫码枪有时会连发两次回车,或者收银员手快又按了一次,导致同一件商品被加两次。
// Cashier.vue —— 扫码输入处理 data() { return { barcode: '', lastScanTime: 0 }; }, methods: { onBarcodeEnter() { const now = Date.now(); // 300ms 内的重复回车视为扫码枪连发,直接丢弃 if (now - this.lastScanTime < 300) { this.barcode = ''; return; } this.lastScanTime = now; const code = this.barcode.trim(); this.barcode = ''; if (!code) return; this.addToCart(code); }, async addToCart(code) { const res = await getGoodsByBarcode(code); if (!res.data) { this.$message.warning('未找到条码:' + code); return; } const exist = this.cart.find(i => i.goodsId === res.data.id); if (exist) { exist.qty += 1; // 已在购物车则数量加一 } else { this.cart.push({ ...res.data, qty: 1 }); } this.calcTotal(); } }lastScanTime是防连发的关键,300 毫秒这个阈值是根据常见扫码枪的出码间隔定的,太快会误杀连续扫两件同款商品的正常操作,太慢又挡不住连发。addToCart里做了同商品合并,购物车里一件商品只有一行,数量累加,这样小票打印出来也清爽。calcTotal负责重算应收、优惠、抹零,任何一次增删改数量后都必须调一次,别指望 Vue 的 computed 能覆盖所有场景——涉及四舍五入的地方手动算更稳。
3.2 下单事务:扣库存、写订单、记流水必须在一个事务里
结算按钮按下去,后端要做的事按顺序是:校验库存、扣减库存、写订单主表、写订单明细、写库存流水、累加会员积分、生成小票数据。这七步任何一步失败都必须整体回滚,否则就会出现"钱收了但库存没减"或者"订单写了但明细丢了"这种对不上账的情况。
// SaleOrderServiceImpl.java —— 收银下单主事务 @Transactional(rollbackFor = Exception.class) public OrderResultVO checkout(CheckoutDTO dto) { // 1. 幂等锁:同一台收银机 + 同一请求号只处理一次 String lockKey = "pos:checkout:" + dto.getClientNo() + ":" + dto.getRequestId(); if (!redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS)) { throw new BizException("请勿重复提交"); } try { BigDecimal total = BigDecimal.ZERO; List<SaleOrderItem> items = new ArrayList<>(); for (CartItemDTO ci : dto.getItems()) { // 2. 条件更新扣库存,stock >= qty 才允许扣,防超卖 int affected = goodsMapper.deductStock(ci.getGoodsId(), ci.getQty()); if (affected == 0) { throw new BizException("商品[" + ci.getGoodsName() + "]库存不足"); } Goods goods = goodsMapper.selectById(ci.getGoodsId()); // 3. 单价取数据库当前售价,不信任前端传值 BigDecimal amount = goods.getSalePrice() .multiply(BigDecimal.valueOf(ci.getQty())) .setScale(2, RoundingMode.HALF_UP); total = total.add(amount); items.add(buildItem(goods, ci.getQty(), amount)); } // 4. 会员折扣与抹零在服务端统一算,前端算的只用于展示 BigDecimal payable = memberService.applyDiscount(dto.getMemberId(), total); SaleOrder order = buildOrder(dto, total, payable); saleOrderMapper.insert(order); items.forEach(i -> i.setOrderId(order.getId())); saleOrderItemMapper.insertBatch(items); // 5. 库存流水逐条落库,方便日后对账 stockRecordService.batchSave(order.getOrderNo(), items); memberService.addPoints(dto.getMemberId(), payable); return new OrderResultVO(order.getOrderNo(), payable, dto.getPaidAmount().subtract(payable)); } finally { redisLock.unlock(lockKey); } }几个参数值得单独说明。clientNo是收银机编号,requestId是前端生成的 UUID,两者组合成幂等键,能挡住收银员手抖连点两次结算的情况。deductStock对应的是下面这条 SQL,靠stock >= #{qty}这个条件在数据库层面兜底,比先查再减安全得多:
UPDATE goods SET stock = stock - #{qty}, update_time = NOW() WHERE id = #{goodsId} AND stock >= #{qty};返回影响行数为 0 就说明库存不够,直接抛异常回滚。单价一定要从数据库取,不能信前端传的价格——这是收银系统最基本的安全底线,改价必须走后端授权的改价接口并留痕,绝不能在前端随便改个数字就提交。
3.3 金额计算:BigDecimal 与抹零、找零的处理
收银系统里所有跟钱有关的字段,Java 侧一律BigDecimal,数据库侧一律DECIMAL(10,2),没有例外。用double算0.1 + 0.2得到0.30000000000000004这种事,在收银台就是实实在在的客诉。每次乘除之后必须setScale(2, RoundingMode.HALF_UP),把精度钉死在分。
抹零是另一处容易翻车的地方。常见做法有三种:四舍五入到角、直接舍去分、满整抹零。工程里把抹零策略做成了配置项pos.checkout.round-mode,取值NONE / HALF_UP / DOWN,结算时按配置处理,抹掉的金额记在订单的discount_amount字段里,单独统计。找零则是实收 - 应收,实收小于应收直接拦截,不允许负找零。
3.4 小票打印:58mm 热敏纸的排版与打印触发
打印这块,前端方案是页面上藏一个#receipt容器,结算成功后把订单数据填进去,调window.print(),剩下的交给 CSS。关键是@page的尺寸要和纸宽对上,58mm 的机器写 80mm 会切边,80mm 的写 58mm 会留一大片空白。
@media print { @page { size: 58mm auto; margin: 0; } body * { visibility: hidden; } #receipt, #receipt * { visibility: visible; } #receipt { width: 58mm; font-size: 12px; font-family: "SimSun", monospace; line-height: 1.4; } #receipt .row { display: flex; justify-content: space-between; } #receipt .divider { border-top: 1px dashed #000; margin: 4px 0; } }visibility: hidden配合子元素visible是隐藏页面其余部分的标准写法,比display: none稳,后者在某些浏览器里会把打印区域一起干掉。字体选宋体或等宽字体,因为热敏打印机对比例字体支持差,等宽字体才能保证金额右对齐。小票内容一般包括店名、订单号、收银员、商品明细、合计、优惠、实收、找零、支付方式、打印时间,最后留一行"谢谢惠顾"。
如果项目要接的是 USB 口或串口的 ESC/POS 指令打印机,那就得走后端方案:Java 侧拼装指令字节流,通过javax.usb或串口库直接推给打印机。工程里预留了PrinterService接口,默认实现是浏览器打印,需要换硬件时新增一个实现类即可,不用改调用方。
4. 本地部署实战:数据库、配置项与前后端联调
源码能不能跑起来,取决于三件事:数据库脚本是否完整、配置项是否对得上、前后端代理是否通。按下面顺序走,一般半小时内能看到收银台界面。
4.1 环境准备与数据库初始化
先确认本机有 JDK 8 或 11、Maven 3.6+、Node 14+、MySQL 8.0、Redis 5+。版本别乱跳,Spring Boot 2.5 配 JDK 17 会有一堆反射相关的告警,新手容易被吓到。Redis 主要用来做结算幂等锁和会员会话缓存,没有它系统也能跑,但并发结算会退化。
# 1. 建库,字符集必须 utf8mb4,否则商品名里的生僻字会变问号 mysql -uroot -p -e "CREATE DATABASE pos DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入表结构与初始数据 mysql -uroot -p pos < pos-server/src/main/resources/db/pos.sql # 3. 编译并启动后端 cd pos-server mvn clean package -DskipTests java -jar target/pos-server-1.0.0.jar # 4. 安装依赖并启动前端 cd ../pos-web npm install --registry=https://registry.npmmirror.com npm run servepos.sql里会插入一个默认管理员账号和十几条测试商品,商品条码是模拟的 13 位数字,可以直接手输测试,不用真拿扫码枪。导入完先SELECT COUNT(*) FROM goods;确认一下数据进去了,很多人卡在"登录成功但商品列表空",八成是脚本只导了结构没导数据。
4.2 后端配置:application.yml 里必须改的几项
配置文件里跟环境相关的就那么几项,改错一个就起不来。重点是数据源 URL 里的时区和编码参数,以及 Redis 的库号——本地 Redis 常常被别的项目占着 0 号库,这里指定一个独立库号能省掉键冲突的麻烦。
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/pos?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 改成你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 3 pos: checkout: round-mode: HALF_UP # 抹零策略:NONE / HALF_UP / DOWN printer: paper-width: 58 # 小票纸宽,58 或 80 store-name: 示例门店serverTimezone=Asia/Shanghai必须加,不然日结报表按时间分组时会整体偏移八小时,凌晨那几笔单子会跑到前一天去。allowPublicKeyRetrieval=true是 MySQL 8 默认加密方式下连接的必要参数,漏了会报Public Key Retrieval is not allowed。round-mode改成NONE就是不抹零,方便对账时排查差异。
4.3 前端启动与接口联调
前端开发态通过vue.config.js里的 proxy 把/api转发到 8080,生产环境则交给 Nginx。这一步配错的典型症状是登录页一直转圈,控制台报 404 或跨域。
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true, pathRewrite: { '^/api': '' } // 后端接口没有 /api 前缀,转发时剥掉 } } } }pathRewrite这一行是坑点,后端@RequestMapping("/goods")没有/api前缀,前端如果按/api/goods请求,不剥前缀就会 404。启动后访问http://localhost:8081,用 SQL 里插入的管理员账号登录,进收银台页面,手动输入一个测试条码回车,能看到商品进购物车,链路就通了。
4.4 收银端外设接入:扫码枪、钱箱、小票机
扫码枪是 USB 免驱的,插上就能当键盘用,唯一要做的是在收银页面加载完成后自动让条码输入框获得焦点,并监听全局的focus丢失事件重新聚焦——收银员点了别的地方再扫码,字符会打到一个看不见的地方。钱箱一般接在小票机的 RJ11 口上,打印小票时打印机发出的开箱指令会顺带弹开,所以只要小票能打,钱箱就能开。
小票机装好驱动后在浏览器打印对话框里选它,把"边距"设为无、"缩放"设为 100%,然后勾选"另存为默认设置",收银员就不用每次调了。这一步没什么技术含量,但实测下来是新手最容易卡住的地方,因为浏览器默认缩放经常把 58mm 的小票打成半截。
5. 常见问题排查:条码扫不进、库存对不上、小票乱码这几类坑
这套系统在本地跑通只是开始,真正上线后遇到的问题大多集中在输入、并发、精度和硬件四类。下面这几条都是高频翻车现场,按"现象 → 原因 → 解决"整理。
5.1 扫一件商品进了两行,或者条码根本没反应
现象:扫一次条码,购物车里出现两条同样的商品;或者扫了没反应,得手动点一下输入框才行。
原因:前一种是扫码枪连发回车,或者全局 keydown 监听和输入框的回车事件同时触发,导致addToCart被调用两次;后一种是页面某个弹窗关闭后焦点没回到输入框,字符打到了 body 上。
解决:一是加时间戳防抖,300ms 内的重复回车直接丢弃;二是把回车监听挂在输入框上用@keyup.enter,不要同时在document上再挂一个。弹窗关闭后用this.$nextTick(() => this.$refs.barcodeInput.focus())把焦点抢回来,这个$nextTick不能省,DOM 还没渲染完就 focus 会失效。
5.2 库存扣成了负数,或者两个收银台同时卖断货
现象:后台看商品库存是 -3,或者明明只剩 1 件,两个收银台同时结算都成功了。
原因:用了"先 select 查库存,再 update 减库存"的写法。两条 SQL 之间有时间窗,两个事务都读到 1,都判断够,都去减,结果就是超卖加负库存。
解决:换成条件更新,把判断塞进 SQL 的 where 里,靠数据库行锁保证原子性:
UPDATE goods SET stock = stock - #{qty} WHERE id = #{goodsId} AND stock >= #{qty};然后判断返回的影响行数,0 就抛异常回滚。这条规则对退货回补同样适用,stock = stock + #{qty}也要走同一条 Mapper 方法族,别图省事用updateById传个对象过去,那样会绕过条件判断。
5.3 小计和合计差一分钱,顾客当场质疑
现象:三件商品各 3.33 元,小票上小计分别 3.33,合计却显示 9.98 而不是 9.99。
原因:每行amount都做了setScale(2, HALF_UP),但合计是拿已经舍入过的行金额相加,还是拿未舍入的原始值相加,两处口径不一致就会差一分。更糟的是有人用了double累加。
解决:统一口径——每行先算原始金额,累加时用未舍入值,最后对总计做一次setScale。同时把数据库字段类型改成DECIMAL(10,2),实体类用BigDecimal,application.yml里配好round-mode,让抹零只发生在最后一步。
BigDecimal rawTotal = items.stream() .map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQty()))) .reduce(BigDecimal.ZERO, BigDecimal::add); // 累加阶段不舍入 BigDecimal payable = rawTotal.setScale(2, RoundingMode.HALF_UP); // 只在最后舍入一次5.4 小票打出来是乱码,或者只打了半个页面
现象:中文全变成方块或问号;或者一页纸只打了两行字就切断。
原因:乱码通常是编码没统一,浏览器、HTML 的charset、CSS 字体三者有一个不是 UTF-8 或系统里没装对应字体;打半页则是@page size和实际纸宽不匹配,或者打印对话框里的缩放不是 100%。
解决:HTML 头部声明<meta charset="utf-8">,CSS 里把font-family指定成系统确有安装的宋体或黑体;@page的size严格按纸宽写58mm auto,margin: 0;打印对话框里把边距设无、缩放设 100%,并保存为默认。如果换到 80mm 机器,除了改@page,还要把配置里的paper-width一起改,两处必须同步。
5.5 日结报表总数对不上,凌晨的单子跑到昨天去了
现象:昨天营业结束后对账,现金应该收 5000,报表显示 4800,差的正好是晚上十一点半之后那几笔。
原因:报表按create_time分组统计,而 MySQL 连接串没配serverTimezone,驱动用 UTC 解析,时间整体偏移八小时,跨零点的订单就被算到前一天了。
解决:连接串加serverTimezone=Asia/Shanghai,同时订单表增加一个business_date字段,结算时由后端按门店配置的营业日切分规则写入(比如凌晨两点前算前一天)。报表一律按business_date统计,不再碰create_time。这个字段看起来冗余,但做过零售对账的人都知道,营业日和自然日从来就不是一回事。
6. 二次开发改造:促销引擎、支付扩展与报表口径
拿到源码后最常被提的需求是"加个满减""接个聚合支付""报表加个毛利维度"。这三件事分别对应三个扩展点,改对了地方,代码量都不大。
促销这块,工程里MemberService.applyDiscount目前只做了会员折扣,要扩成规则引擎,别在里面堆if-else,用策略模式最合适。定义一个PromotionStrategy接口,方法签名BigDecimal apply(CartContext ctx, BigDecimal amount),然后满减、第二件半价、满赠各写一个实现类,用@Component注册到 Spring,再用一个PromotionChain按优先级排序依次执行:
public interface PromotionStrategy { int priority(); // 数值越小越先执行 boolean match(CartContext ctx); // 是否命中该规则 BigDecimal apply(CartContext ctx, BigDecimal amount); } @Component public class FullReduceStrategy implements PromotionStrategy { public int priority() { return 10; } public boolean match(CartContext ctx) { return ctx.getAmount().compareTo(new BigDecimal("88")) >= 0; } public BigDecimal apply(CartContext ctx, BigDecimal amount) { return amount.subtract(new BigDecimal("10")).max(BigDecimal.ZERO); } }支付扩展同理,定义一个PayChannel接口,现金、扫码、会员余额各一个实现,收银端只传payType,后端按类型分发。将来接新的支付方式,新增一个实现类就行,不用动结算主流程。报表口径则要守住一条:所有涉及金额的字段都以business_date为维度聚合,退货用负数金额冲抵,不要单独做一张退货表再去关联,那样口径必然分叉。
改完怎么验证?我一般会跑三组数据:一件单价 0.01 元的商品买三件,看总价是不是 0.03;库存为 1 的商品用两个线程并发结算,看是不是只有一个成功;跨零点打印一张小票,看报表里落在哪一天。这三组过了,基本的功能改造就稳了。从那以后我每次改金额和库存相关的代码,都强制把这三组用例走一遍,宁可多花十分钟,也不想再被店长追着问"昨天的账怎么少了二十块"。希望这些能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取