☰
超市收银系统源码实践:从建库建表到事务一致性的完整闭环
2026/10/14 23:58:58 网站建设 项目流程

简介:这套超市收银系统源码是一份面向计算机相关专业学生的毕业设计参考项目,重点解决小型超市商品录入、销售结算与库存跟踪等日常管理难题。系统模块覆盖添加商品、前台收银、商品查询和计算器小工具,同时兼顾库存管理、会员管理、报表分析、支付接口、退货退款与权限控制等扩展功能,适合用来学习从数据库设计、前端界面到后端逻辑处理与系统集成的完整开发流程。资源包以RAR压缩格式发布,整体约3.3MB,压缩包内含源代码、数据库配置文件及用户使用说明等辅助资料,便于直接查阅和二次开发。目前已有3115人学习下载。对于希望在毕业设计中快速搭建小型商业应用、理解收银系统各功能模块间协作关系的开发者而言,这套源码提供了清晰的项目骨架和可运行的实践起点,有助于将理论知识与实际业务场景相结合。

1. 超市收银系统源码:看起来是个压缩包,实际上是一整套交易闭环

下载一份超市收银系统源码,第一件事通常是导入数据库、改密码、点运行,然后被报错拦在门口:JDBC 驱动找不到、表结构对不上、结账金额带出小数尾巴、退个货把库存扣成负数。不少人的时间耗在界面美化上,反而忽略了这套系统的命门——交易数据的完整性。

超市收银系统源码,拆开看是一个完整业务闭环:商品建档、扫码、购物车、结算、小票打印、库存扣减、退货、日终报表。这篇文章就围绕这个闭环讲,从建库建表到事务处理,再到报表和部署,代码按可直接复现的程度给出。

适合两类人:一类是课程设计、毕业设计需要一个能讲清楚业务闭环的完整项目,另一类是想给社区小店做轻量收银方案的开发者。前者重点看第二到第四章,后者请把第五、六章一起读完。

2. 拆解收银业务:先建表还是先写界面,答案藏在订单流里

2.1 从一张小票反推数据表:商品、订单、明细、库存流水四张核心表

收银小票上能看到的字段,就是整个业务的最小集合:商品名称、数量、单价、小计、合计、单号、交易时间、收银员、支付方式。顺着这张小票反向设计数据库,四张表基本够用:product(商品档案)、orders(订单主表)、order_items(订单明细)、stock_log(库存流水)。

为什么订单要拆成主表和明细两张?因为一次结算可能包含多种商品,「一单多品」是一对多关系,拆开才能支撑「一张小票多种商品」这个基本场景。如果图省事把所有商品拼成一个字段存,日终统计品类销售时要写噩梦级的解析 SQL,数据稍微一多就卡死。至于 stock_log,很多收银系统源码里根本没有这张表,库存变动只能靠猜,退货或者盘点差异根本说不清。我一般会把库存流水当成和订单同等重要的表来设计——哪怕只是课程设计,加上它,答辩时讲数据一致性都更有底气。

字段设计有几个细节值得一开始就定死:价格全部用 DECIMAL(10,2),不要用 DOUBLE;条码做唯一索引,因为扫码收银是按条码定位商品;商品表里放一个 status 字段做上下架,而不是直接 DELETE——收银系统要保留历史销售记录,删商品会破坏订单明细的关联。品类先存一个字符串分类,不要急着建品类表,等报表需求真正出现再拆不迟,过早建模只会让自己陷入改表的泥潭。

2.2 技术选型:Java、Python、PHP 与桌面、Web 两种形态怎么选

网上搜「超市收银系统源码」,常见技术栈无非四种:Java Swing 桌面版、Java Web(含 Spring Boot)、Python Tkinter、PHP 网页版。选型不是越新越好,得看使用场景和交付对象。

我的倾向很明确:课程设计、毕业设计,优先 Java Swing + MySQL。理由有三:一是逻辑直白,一个方法就是一条业务链路,答辩时好讲;二是 JDBC 裸写 SQL,数据库原理能真正露出来;三是 JFrame 界面双击就能跑,不依赖浏览器和服务器。如果目标是真实上线、同时开两三台收银机,就直接上 Java Web 或者 Spring Boot,桌面版在多终端共享数据库时会冒出一堆缓存一致性问题。Python Tkinter 适合快速验证流程,不想装 MySQL 可以配 SQLite,但并发和报表能力弱,数据量上来要吃性能的亏。PHP 版源码存量最多,但多数是十年前的老代码,字符集和 PHP 版本兼容问题能把人折磨到怀疑人生,不建议新项目选它。

形态适合场景上手难度主要坑
Java Swing + MySQL课程设计、单机演示中多终端并发、中文乱码、打包体积
Java Web / Spring Boot小店多终端、真实上线中高部署环境、连接池、权限
Python Tkinter + SQLite快速验证、内部工具低并发差、报表弱
PHP 网页版老源码参考低字符集、版本兼容

选型定了就不要再摇摆。常见做法是看到某个界面截图好看就换技术栈重写,结果一个学期过去还在搭环境。技术栈只是手段,评分和上线看的是业务闭环是否完整,同样的 SQL 和事务逻辑,换层界面皮不会让你的系统变强。

2.3 项目目录与启动顺序:最小可跑骨架长什么样

拿到源码跑不起来,八成是启动顺序没人写清楚。一个能跑的最小骨架,我一般这样组织:

supermarket-cashier/ ├── sql/ │ └── init.sql # 建库建表 + 测试商品数据 ├── src/main/ │ ├── java/com/retail/ │ │ ├── Main.java # 入口:初始化连接池并打开登录窗口 │ │ ├── db/DB.java # HikariCP 连接池封装 │ │ ├── dao/ProductDao.java # 商品增删改查 │ │ ├── dao/OrderDao.java # 订单与明细写入 │ │ ├── service/CheckoutService.java # 收银事务核心 │ │ └── ui/LoginFrame.java # 登录界面 │ └── resources/ │ └── db.properties # 数据库连接配置 └── README.md # 启动顺序写在这里

启动顺序固定三步:先执行 sql/init.sql 建库并导入测试数据,再改 db.properties 里的数据库密码,最后运行 Main.java。这个顺序必须写进 README 的第一段,因为绝大多数运行失败都发生在第一步和第二步之间——库没建好就点启动,报一堆 Table doesn't exist;密码没改对,报 Access denied。我在给模拟项目X写这套骨架时养成了一个习惯:init.sql 里把建表和测试数据放同一份,新环境一条命令就能初始化,省掉大量手工造数据的时间。

db.properties 的配置项很少,但每个都有讲究:

jdbc.url=jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的密码 pool.maximumPoolSize=10 pool.minimumIdle=2

参数说明:serverTimezone 必须显式指定,高版本 JDBC 会因时区问题直接拒绝连接;maximumPoolSize 对单店收银 10 个就够,调大反而拖垮数据库;minimumIdle 保持 2 个空闲连接,避免高峰时现建连接。密码这块,课程设计写死没关系,真实上线一定要换成环境变量注入,否则源码泄露等于数据库裸奔。

3. 核心交易链路落地:从建库建表到小票打印的完整代码

3.1 建库建表 DDL:用 30 行 SQL 把商品、订单、明细钉死

四张表的 DDL 是整套系统的地基,我按字段逐个说清楚为什么这么建:

CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4; USE supermarket; CREATE TABLE product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, barcode VARCHAR(32) NOT NULL UNIQUE COMMENT '条码,扫码定位用', name VARCHAR(64) NOT NULL COMMENT '商品名称', category VARCHAR(32) DEFAULT '' COMMENT '品类,如饮料/零食', price DECIMAL(10,2) NOT NULL COMMENT '售价', cost DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '进价', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=上架 0=下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_barcode (barcode) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品档案'; CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务单号,对客展示', total_amount DECIMAL(10,2) NOT NULL COMMENT '实收金额', pay_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=现金 2=微信 3=支付宝', cashier VARCHAR(32) NOT NULL COMMENT '收银员账号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE order_items ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL COMMENT '关联订单主表', product_id BIGINT NOT NULL COMMENT '关联商品', barcode VARCHAR(32) NOT NULL, name VARCHAR(64) NOT NULL COMMENT '冗余商品名,防止改价后历史账单失真', unit_price DECIMAL(10,2) NOT NULL COMMENT '成交单价,冻结这一刻的价格', qty INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL COMMENT '单价*数量', KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细'; CREATE TABLE stock_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_id BIGINT NOT NULL, change_qty INT NOT NULL COMMENT '正数入库,负数出库', order_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联单号', reason VARCHAR(32) NOT NULL COMMENT 'sale/refund/restock', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水';

几个必须讲透的选型逻辑。price 和 cost 用 DECIMAL 不用 DOUBLE,是避免二进制浮点带来的累计误差,这一条几乎决定了你对账时会不会哭;order_items 里冗余了 barcode、name、unit_price 三个字段,是为了让历史账单不受商品改价、改名影响——收银系统的小票必须还原「当时」的价格,而不是「现在」的价格,这是很多半吊子源码会漏掉的关键点。订单主表的 order_no 加 UNIQUE 约束,是给后面对账和退货锚定一个业务单据号。

参数说明:utf8mb4 支持中文和 emoji 商品名,比 utf8 稳妥;InnoDB 是事务型引擎,收银绝对不能用 MyISAM;create_time 全部由数据库默认值生成,不要相信客户端时间,多台收银机的时间差会直接毁掉日终报表。

3.2 收银核心代码:扣库存、写流水、落订单的三段式事务

收银是整个系统最核心的一段代码,难度不在 SQL 本身,而在「要么全成功,要么全失败」。下面这段是 Java JDBC 直接写事务的经典写法,我把它拆成九步注释:

public void checkout(String cashier, List<CartItem> cart) throws Exception { Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 1. 关自动提交,开启事务 String orderNo = genOrderNo(); // 2. 生成业务单号 BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart) { // 3. 条件更新扣库存:库存不足时受影响行数为 0 String sql = "UPDATE product SET stock = stock - ? " + "WHERE id = ? AND stock >= ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, item.getQty()); ps.setLong(2, item.getProductId()); ps.setInt(3, item.getQty()); if (ps.executeUpdate() == 0) { throw new RuntimeException("商品[" + item.getName() + "]库存不足"); } } // 4. 写库存流水,出库记负数,方便日后对账 insertStockLog(conn, item.getProductId(), -item.getQty(), orderNo, "sale"); // 5. 累加金额并插入明细分录 total = total.add(item.getUnitPrice() .multiply(BigDecimal.valueOf(item.getQty()))); insertOrderItem(conn, orderNo, item); } insertOrder(conn, orderNo, total, cashier); // 6. 最后写订单主表 conn.commit(); // 7. 全部成功才提交 } catch (Exception e) { if (conn != null) conn.rollback(); // 8. 任何一步失败整体回滚 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 9. 恢复默认,归还连接池 conn.close(); } } }

逻辑说明:第 3 步是关键中的关键,把「库存够不够」的判断直接写进 UPDATE 的 WHERE 条件,stock >= ?保证只有库存充足时才扣减,受影响行数为 0 就抛异常,这样并发下两台收银机同时卖最后一个商品时,只有一台能成功。第 4 步写库存流水,负数代表出库,配合 reason 字段能区分 sale、refund、restock 三种来源。第 6 步把订单主表放在最后插入,是因为主表金额依赖明细累加,如果先插主表后算明细,出错时会出现主从数据不一致。

参数说明:dataSource 是连接池对象(HikariCP),它负责分配和回收连接;CartItem 至少要有 productId、name、qty、unitPrice 四个字段;genOrderNo() 生成业务单号,单店场景用「日期 + 自增序号」足够,多终端必须换雪花算法,否则并发撞号。整段代码的核心纪律是:主表、明细、库存、流水必须用同一个 Connection,任何一步失败,前面所有写入全部回滚。

3.3 小票拼装与打印驱动:不接打印机也能验证格式

小票打印看着是玄学,其实就是「文本拼装 + 打印机指令」两件事。先拼文本,58mm 热敏纸每行约 32 个半角字符,中文占两个,名称超长要截断:

public String buildReceipt(Order order, List<OrderItem> items) { StringBuilder sb = new StringBuilder(); sb.append(" 收 银 小 票\n"); sb.append(" 单号: ").append(order.getOrderNo()).append("\n"); sb.append(" 时间: ").append(order.getCreateTime()).append("\n"); sb.append("------------------------------\n"); for (OrderItem item : items) { sb.append(String.format("%-12s", truncate(item.getName(), 12))) .append(" x").append(item.getQty()).append("\n"); sb.append(String.format(" %.2f\n", item.getSubtotal())); } sb.append("------------------------------\n"); sb.append(" 合计: ").append(String.format("%.2f", order.getTotalAmount())).append("\n"); sb.append(" 支付: ").append(payTypeName(order.getPayType())).append("\n"); sb.append(" 欢迎再次光临\n"); return sb.toString(); }

说明:每行先输出名称再输出数量,对齐用 String.format 的宽度控制;合计金额格式化到两位小数。这段代码不依赖打印机,直接 System.out.println 就能在控制台验证排版,我习惯在写打印驱动之前先跑这条路径,把格式问题隔离在硬件之外。

真正接打印机时,常见驱动是 ESC/POS 指令集:

OutputStream out = printer.getOutputStream(); out.write(new byte[]{0x1B, 0x40}); // ESC @ 初始化打印机,清残留状态 out.write(receipt.getBytes("GBK")); // 多数国产热敏打印机默认 GBK 字符集 out.write(new byte[]{0x0A, 0x0A, 0x0A}); // 换行走纸三行,避免小票尾巴贴在切刀口 out.flush();

参数说明:0x1B 0x40 是初始化指令,打印前必须发,否则上一单的残留状态会污染这一单;字符集按打印机手册来,绝大多数国产 58mm 热敏打印机出厂是 GBK,你用 UTF-8 字节直接送过去,中文全部变成问号;末尾补三个换行是为了走纸,不然下一张小票会紧贴切纸刀,顾客撕票时撕坏。用到这些指令时先查 ESC/POS 手册,不同厂商的扩展指令有差异,我只保证这三条是通用到几乎所有机型。

4. 收银系统源码的 6 个常见翻车点与排查方法

4.1 金额对不上:浮点结算把 0.1 存成了 0.30000000000000004

现象:小票合计显示 0.30000000000000004,或者 19.90 × 3 算出来 59.699999999999996,日结对账永远差几分钱。

原因:double 和 float 是二进制浮点类型,十进制小数在内存里无法精确表示,累加越多误差越大。这是二进制浮点的数学性质,和代码写没写错没关系。

解决:Java 侧一律用 BigDecimal 的字符串构造,数据库侧一律 DECIMAL(10,2)。注意new BigDecimal(0.1)仍然带误差,必须new BigDecimal("0.1"):

BigDecimal price = new BigDecimal("19.90"); BigDecimal total = price.multiply(BigDecimal.valueOf(3));

说明:BigDecimal.valueOf(3) 走的是字符串转换路径,安全;乘法结果赋值给 DECIMAL 列时会再被数据库校验一次精度。这条是血泪经验,我见过整套系统因为一个 double 字段,日终报表每天差出几块钱,查了一夜最后定位到商品单价类型。

4.2 库存越卖越负:先查后扣的写法在并发下必然翻车

现象:两台收银机同时卖同一个商品,库存从 5 变成 -3,超市还没卖货就先欠货了。

原因:常见写法是先SELECT stock FROM product WHERE id = ?,在 Java 里判断库存够不够,再UPDATE product SET stock = stock - ?。两条 SQL 之间的窗口期,另一个连接也能读到同一个库存值,两边都判断「够」,结果双双扣成负数。

解决:把判断写进 UPDATE 条件,数据库在行锁内完成「判断 + 扣减」,天然原子:

UPDATE product SET stock = stock - 2 WHERE id = 11 AND stock >= 2;

说明:受影响行数为 0 就说明库存不足,业务侧抛异常回滚事务。SELECT ... FOR UPDATE 也能解,但条件更新更简单、不占行锁时间,收银高频场景下我优先条件更新。

4.3 订单明细丢失:一个方法里两个连接,事务形同虚设

现象:订单主表有记录,明细只有一半,退货时找不到要退的商品,顾客维权只能手写退款单。

原因:DAO 层每个方法自己取连接、自己关连接,主表写入和明细写入用了两个 Connection,各自独立提交。第一段事务成功了,第二段事务失败,主表就留下一条「无头」订单。

解决:整个结算过程共用一个 Connection,setAutoCommit(false),统一 commit 或 rollback。排查手法很直接:在第三个方法里打个断点看连接对象实例,不是同一个就是事务没生效。

// 错误示范:两个 DAO 各自 getConnection() // orderDao.insert(conn, order); // connA // itemDao.insert(conn, items); // connB —— 事务被拦腰截断

说明:正确做法是第三章 checkout 方法那样,连接从外部传入,DAO 只干活不管理连接生命周期。这条同样适用于库存扣减和流水写入,四张表必须同生共死。

4.4 小票乱码与打印机异常:编码和走纸是两回事

现象:小票中文全部变成 ? 或乱码,还有的打一张要手动撕纸,连打时内容叠在一起。

原因:两个独立问题。乱码是编码不匹配——程序按 UTF-8 输出,打印机默认 GBK;叠票是缺少初始化指令和走纸指令,打印机状态被上一单残留污染。

解决:打印前先发初始化指令,文本按打印机字符集编码,打完补走纸:

out.write(new byte[]{0x1B, 0x40}); // 初始化 out.write(receiptText.getBytes("GB18030")); out.write(new byte[]{0x0A, 0x0A, 0x0A}); // 走纸

说明:GB18030 是 GBK 的超集,兼容性更好,不确定打印机具体字符集时用它最稳。另外记得打印流写完必须 flush,很多人乱码其实是缓冲区的半截字节被截断造成的。

4.5 数据库连接耗尽:早高峰一到就卡死

现象:开门营业两小时后系统越来越慢,日志报 Connection is not available, request timed out,重启才好,第二天又复发。

原因:DAO 里的 Connection 没 close,或者每操作都 new 一个连接且从不关闭,连接池被慢慢吃光。这个方法一多,泄漏就像水龙头滴水,早高峰一压就爆。

解决:统一用连接池并规范归还。连接要么 try-with-resources,要么 finally 里 close,和事务代码同一纪律。参数上单店场景这样起步:

HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl("jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai"); cfg.setUsername("root"); cfg.setPassword("你的密码"); cfg.setMaximumPoolSize(10); cfg.setMinimumIdle(2); DataSource ds = new HikariDataSource(cfg);

说明:maximumPoolSize 不是越大越好,10 个连接对单店收银绰绰有余,调大反而增加数据库切换开销。排查看线程 dump:哪段代码占据连接不归还,一目了然。

4.6 订单号撞车与日期边界:BETWEEN 查询吃进第二天数据

现象:两个终端同时结账,订单号重复;日报表把 1 月 2 日零点后的订单算进了 1 月 1 日。

原因:订单号用了秒级时间戳甚至自增主键直接展示,多终端并发必撞;日期统计用了BETWEEN '2024-01-01' AND '2024-01-02',而 BETWEEN 的右边界是包含的,把 1 月 2 日的零点数据也带了进来。

解决:日期统计统一改成「左闭右开」:

WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'

说明:订单号单店用「yyyyMMddHHmmss + 终端号 + 序号」足够,多终端上量就换雪花算法,不要用数据库自增主键对外展示,会泄露业务量且不好做跨库迁移。这几个坑排查顺序建议是:先查连接是否共用,再查类型是否 DECIMAL,最后查日期写法,九成收银源码的毛病都在这三处。

5. 把流水变成报表:库存预警、日销售统计与退货处理

5.1 日销售统计与时段分布:三条 SQL 覆盖九成报表需求

收银系统的价值不在收银本身,而在数据。日销售报表、时段分布、品类占比,这三张表基本覆盖小超市老板的日常诉求,每一条 SQL 都值得直接抄:

-- 日销售汇总:单量、销售额 SELECT DATE(create_time) AS day, COUNT(*) AS order_cnt, SUM(total_amount) AS sales_amount FROM orders WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00' GROUP BY day; -- 时段分布:看早中晚高峰排班 SELECT HOUR(create_time) AS hour, COUNT(*) AS order_cnt, SUM(total_amount) AS amount FROM orders WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00' GROUP BY hour ORDER BY hour; -- 品类销售占比:关联商品表按分类汇总 SELECT p.category, SUM(oi.subtotal) AS amount FROM order_items oi JOIN product p ON p.id = oi.product_id JOIN orders o ON o.id = oi.order_id WHERE o.create_time >= '2024-01-01 00:00:00' AND o.create_time < '2024-02-01 00:00:00' GROUP BY p.category ORDER BY amount DESC;

参数说明:日期过滤全部用左闭右开,避免 BETWEEN 右边界吃进次日数据;orders 表在 create_time 上建了索引,这三条 SQL 在小几十万行级别都不需要额外优化。品类占比算占比时,在 Java 里拿 SUM 总额再除,别在 SQL 里写相关子查询,数据一多会拖慢报表。这是把数据变成经营价值的起点,老板关心的不是代码多优雅,而是「今天卖了多少、哪个时段人多、什么品类赚钱」。

5.2 库存预警扫描:定时任务别放在收银主线程里

库存预警的原理很简单:定时扫一遍商品表,把低于阈值的商品捞出来。但实现位置很有讲究,别在收银请求里顺手扫,那样每次结账都要多一次全表查询,高峰期直接把系统拖垮。正确做法是独立定时任务:

String sql = "SELECT id, name, stock FROM product " + "WHERE status = 1 AND stock <= ?"; int warnLine = config.getInt("stock.warnLine", 5); ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleWithFixedDelay(() -> { // 扫描低库存,写入提醒表或打印补货单 scanLowStock(warnLine); }, 0, 30, TimeUnit.MINUTES);

说明:阈值放在配置文件里,默认 5,小超市饮料这类快消品往往要调到 20;scheduleWithFixedDelay 的含义是本次执行完后隔 30 分钟再执行下一次,比 scheduleAtFixedRate 更适合这种不要求精确定时、怕任务重叠的场景。扫描结果写到提醒表,收银端开屏时拉取,比直接弹窗打断收银体验好。这条设计看着不起眼,却是「能用」和「好用」的分水岭。

5.3 退货与改单:反向流水怎么记才平账

退货是另一个容易把账做平的地方。常见错误做法是直接改原订单的金额和数量,或者直接 DELETE 原明细——这两种都会让日终报表、销售统计和库存流水全部失真,历史账单成了一个黑匣子。

正确做法是「原单不动,单独记一笔反向流水」:生成一张退货单,关联原单号,价格引用原单明细的成交价,数量为正但走退款逻辑,库存加回去,reason 记 refund:

-- 1. 库存回补 UPDATE product SET stock = stock + 2 WHERE id = 11; -- 2. 库存流水记反向 INSERT INTO stock_log (product_id, change_qty, order_no, reason) VALUES (11, 2, 'R20240101001', 'refund');

说明:change_qty 记 +2 表示入库回补,order_no 是退货单号而不是原单号,但退货单里要冗余存一份 original_order_no 字段用来追溯。日终统计时,销售金额 = 正向订单金额 - 退货金额,两边分开统计,对账才有依据。至于改单(改数量、改价格),我一般不提供原地改的入口,统一按「原单退货 + 重新结算」两个动作串起来,代码和账目都能保持简单。

6. 部署验收最后一公里:备份、冒烟测试与交接清单

源码能跑只是第一步,真正敢交给店里用,还要过备份、冒烟和交接三关,每关都有后悔药可吃的时候。

备份是第一道保险。MySQL 用 mysqldump 做每日全量,注意加 --single-transaction 避免锁表影响营业:

mysqldump -uroot -p your_pass supermarket \ --single-transaction --routines \ > /backup/supermarket_$(date +%F).sql

说明:--routines 带上存储过程和函数,别小看这一步,少了它恢复时可能报错。备份文件建议留最近 7 天,磁盘不差这点空间。恢复测试更要练一次,否则真到要恢复那天才发现备份文件是坏的,才是最绝望的时刻。

冒烟测试是我给自己定的一条铁律:任何收银系统改动,先跑一段无人值守的验证脚本,再谈界面和功能。脚本做三件事:开一个测试商品库存为 10,走一次完整结算,断言库存变成 8、订单表多一条记录、库存流水多两条记录。这三条断言全过,核心链路才算出厂合格。把脚本写进 README,换环境部署时一条命令就能验证。

最后是交接清单,我每次交付源码都附一张表,避免对方拿到手不知道从哪开始:

检查项操作通过标准
数据库初始化执行 sql/init.sql四表存在且有测试数据
配置注入修改 db.properties / 环境变量启动无 Access denied
收银链路跑冒烟脚本库存、订单、流水断言全过
日终对账跑日报 SQL汇总金额等于收银台合计
备份恢复备份后删库再恢复数据完整可查

这套流程走完,系统才算真正交付。我最早做模拟项目X时,把功夫全花在按钮美化上,结果被库存负数折腾了一周才知道事务和条件更新的分量。从那以后我养成了一个习惯:任何改动先跑冒烟测试再谈体验,宁可丑一点,不能错一分。希望帮到你。

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

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

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

立即咨询