ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java Swing+JDBC 小型超市商品管理系统源码:收银与库存事务实现

Java Swing+JDBC 小型超市商品管理系统源码:收银与库存事务实现

简介:面向小型自选商场信息化教学与课程设计场景的商品管理系统源码包,围绕进货、销售、库存动态联动和供应商信息管理展开,适合数据库课程设计、C# WinForms 入门实践及小型零售业务建模练习。压缩包共 189 个文件,约 680KB,以 60 个 SQL 脚本和 54 个 C# 源文件为主体,配合 resx/resources 资源、config 配置、exe 程序及 mdf/ldf 数据库文件,覆盖建表、业务逻辑、界面资源与可运行程序等层次。已有 1028 人学习下载。资源内含库存、售货、进货、供应商等表结构设计,以及进货记录与按月统计、销售查询与日/月盘存、库存随进出货实时刷新、供应商信息查询和收银台清单与收付款显示等完整实现思路,便于读者对照理解商品管理系统的数据流与功能模块划分,并在此基础上完成课程作业或二次开发。项目还保留解决方案与资源文件,便于在 Visual Studio 中打开调试和查看界面布局。

1. 从收银台的一次扫码说起:这套系统到底管什么

社区门口的便利店,收银员扫一下条码,屏幕上跳出商品名和价格,库存数字从 37 变成 36——背后其实是一个数据库在记账。这份小型自选商场商品管理系统源码,就是把这段动作完整落成一个能跑起来的桌面程序:商品建档、分类维护、条码录入、入库出库、前台收银、销售流水、库存预警,全在一个 Java 窗口里跑完。它适合两类人:一类是刚接触数据库 商品管理系统课程设计,想要一套表结构说得清、能当场演示的原型;另一类是要给小超市、夫妻店做离线收银工具的开发。整套东西不依赖服务器,一台装了 MySQL 的收银机就能转起来,断网照样开单,而这恰恰是很多联网方案在小店里最先出问题的地方。

2. 技术选型与分层架构:一次扫码请求要穿过哪几层

2.1 为什么是 Swing + JDBC,而不是先上一套 Web

小店收银场景有三个硬约束:机器就一台、网络不保证、老板要双击图标就能用。基于这三点,桌面端 + 本地数据库几乎是唯一合理的解。Web 方案看着现代,但你要多维护一个后端服务、一个端口、一套部署脚本,收银机一重启服务没起来,队伍就堵在门口了。

具体选型是 JDK 17 + Swing 做界面,JDBC 直连 MySQL 8.0,Maven 管依赖和打包。Swing 现在不算时髦,但它零运行时依赖、打包成一个可执行 jar 就能发给客户,对小工具来说省下来的部署成本远超界面美观度的损失。数据访问没有上 MyBatis 或 Hibernate,原因是这套系统的 SQL 本身就简单,全是单表或两表关联,手写PreparedStatement反而更好排查——出问题时你能直接把 SQL 复制到客户端里跑一遍,不用猜框架生成了什么。

有一种替代做法值得考虑:如果客户连装 MySQL 都不愿意,可以把数据源换成 SQLite 或 H2 嵌入式模式,改的只是连接串和驱动依赖,DAO 层几乎不动。这也是下面把连接管理单独抽一个工具类的原因。

2.2 包结构与分层边界:别让 Swing 按钮直接写 SQL

分层这件事在小项目里最容易被执行成摆设。常见的失控路径是:一开始界面里直接conn.prepareStatement,写着写着发现三处地方要扣库存,逻辑各写一遍,某天改规则漏掉一处,账就对不上了。这套源码的分层是按「谁改了什么」划的,职责边界比较硬:

market-desktop ├── pom.xml └── src/main ├── java/com/example/market │ ├── Main.java 启动入口,只负责设外观、开登录窗 │ ├── ui/ Swing 窗体:LoginFrame、MainFrame、CashierPanel… │ ├── service/ 业务规则:结算、入库、库存校验、权限判断 │ ├── dao/ SQL 全在这一层,返回实体,不返回 ResultSet │ ├── entity/ Product、SaleOrder、CartItem 等 POJO │ ├── common/ JdbcUtils、BizException、日期与金额工具 │ └── report/ 统计查询,只读,单独放一包 └── resources ├── db.properties 连接串、账号 └── sql/schema.sql 建库建表脚本

约束只有一条:ui只能调service,service只能调dao,dao里不许出现任何JOptionPane。这条线守住之后,后面加字段、换数据库、写单元测试都有地方下手。report单独拆出来是因为报表查询的特点是「只读、SQL 长、字段多」,塞进dao里会让本来清爽的增删改查文件迅速膨胀到上千行。

2.3 表结构:五张表撑起一本账

账目能不能对得上,八成取决于表设计阶段。这套系统的核心是五张表,商品和分类是主数据,销售主单和明细是流水,库存流水表是事后查账的依据:

表名作用关键字段说明
category商品分类name唯一数量少,可全量缓存到内存
product商品档案barcode唯一、stock、warn_stock条码是扫码枪的入口,必须唯一
sale_order销售主单order_no、total_amount、create_time一单一行,金额存汇总值
sale_item销售明细order_id、product_id、price、qty存下单时的价格快照
stock_log库存变动流水change_qty、type正数入库、负数出库,只增不改

两个设计点值得单独说。第一,sale_item里的price和product_name是快照,不是关联查出来的——商品今天改价、明天改名,上个月的账单不能跟着变,这个后悔药必须在建表时就吃下去。第二,库存没有做成「每次查询SUM(change_qty)」的实时计算,而是在product.stock上存了冗余值,靠事务保证一致。小店一天几百单,实时聚合完全扛得住,但收银界面要的是毫秒级响应,冗余这一下省掉的是每次扫码都算一遍全表。

3. 从建库到点开登录窗口:跑通这套系统的完整步骤

3.1 环境清单与依赖配置

需要准备的东西不多,但版本要对上,尤其是驱动:JDK 17 或以上、Maven 3.8+、MySQL 8.0。驱动用mysql-connector-j8.x,别用老版本的mysql-connector-java5.1.x,两者包名都变了,混着放 classpath 里会出现莫名的ClassNotFoundException。

<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>market-desktop</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.3.0</version> </dependency> </dependencies> <build> <plugins> <!-- shade 把驱动一起打进 jar,客户机只要装了 JRE 就能双击运行 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.market.Main</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin> </plugins> </build> </project>

mainClass这一行必须和你的启动类全限定名完全一致,写错了打出来的 jar 双击只会闪一下窗口,什么提示都没有。shade插件的作用是把依赖解压后重新打进同一个 jar,不打的话客户端会报No suitable driver found——因为驱动根本不在包里。

3.2 建库建表脚本

脚本放在resources/sql/schema.sql,用命令行或客户端工具执行一遍即可。字符集统一utf8mb4,别用utf8,后者在 MySQL 里其实是三字节的残废版本,遇到生僻字或 emoji 商品名会直接报错。

CREATE DATABASE IF NOT EXISTS market_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE market_db; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE, sort_no INT DEFAULT 0 ) ENGINE=InnoDB; CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(20) NOT NULL UNIQUE COMMENT '条码,扫码枪直接录入', name VARCHAR(100) NOT NULL, category_id INT NOT NULL, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, warn_stock INT NOT NULL DEFAULT 10 COMMENT '低于此值触发预警', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 在售 0 下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_name (name), KEY idx_cate (category_id), KEY idx_stock_warn (status, stock, warn_stock), CONSTRAINT fk_product_cate FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB; CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL, pay_type TINYINT NOT NULL DEFAULT 1 COMMENT '1 现金 2 扫码', cashier VARCHAR(30), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_time (create_time) ) ENGINE=InnoDB; CREATE TABLE sale_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL COMMENT '下单时快照', price DECIMAL(10,2) NOT NULL, qty INT NOT NULL, KEY idx_order (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES sale_order(id) ON DELETE CASCADE ) ENGINE=InnoDB; CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, change_qty INT NOT NULL COMMENT '正数入库,负数出库', type TINYINT NOT NULL COMMENT '1 入库 2 销售 3 盘点调整', remark VARCHAR(100), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, create_time) ) ENGINE=InnoDB;

金额一律DECIMAL(10,2),别用FLOAT或DOUBLE,浮点误差累积到几百单之后,日报表和收银机对不上账的时候你会怀疑人生。idx_stock_warn这个联合索引是给库存预警页准备的,WHERE status = 1 AND stock <= warn_stock这种列与列比较的写法用不上普通索引,联合索引至少能把扫描范围收窄到在售商品。

3.3 连接配置与启动入口

连接串是最容易出问题的环节,参数一个都不能少:

# src/main/resources/db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/market_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456

逐项说清楚:serverTimezone=Asia/Shanghai不写的话,Java 取出来的时间和数据库里差 8 小时,日报统计会莫名其妙少掉最后一天的订单;characterEncoding=utf8决定中文能不能正常进出;allowPublicKeyRetrieval=true是 MySQL 8 默认加密插件的要求,不写会报Public Key Retrieval is not allowed;useSSL=false是本地连接,省掉证书握手。

public final class JdbcUtils { private static String url, user, pwd; static { // 类加载时读一次配置,避免每次连接都读文件 Properties p = new Properties(); try (InputStream in = JdbcUtils.class.getClassLoader().getResourceAsStream("db.properties")) { p.load(in); Class.forName(p.getProperty("jdbc.driver")); // 显式加载驱动,老容器里更保险 url = p.getProperty("jdbc.url"); user = p.getProperty("jdbc.username"); pwd = p.getProperty("jdbc.password"); } catch (Exception e) { // 配置缺失属于启动期致命错误,直接抛出去,别吞 throw new ExceptionInInitializerError("数据库配置加载失败:" + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, pwd); } }

getResourceAsStream用的是类路径加载,好处是打包进 jar 之后路径不会变;如果你写成new FileInputStream("db.properties"),开发时能跑,打包后必然FileNotFoundException,这是新手最容易遇到的第一个问题。启动类Main里做三件事就够了:设置UIManager.setLookAndFeel换成系统外观、把JFrame放到EventQueue.invokeLater里创建、打开登录窗。登录成功后把当前用户放进一个静态上下文,后面service层写stock_log时要用到操作人。

4. 核心模块拆解:商品查询与收银扣库存的关键实现

4.1 商品检索:一个条件拼装怎么写才不出错

商品管理页面上有搜索框、分类下拉,两个条件可以任意组合,这种动态 SQL 是最容易写出拼接漏洞的地方。做法是维护一个参数列表,SQL 和参数同步追加,位置严格对齐:

public List<Product> search(String kw, Integer categoryId) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT p.id, p.barcode, p.name, c.name AS cate_name, " + " p.sale_price, p.stock, p.warn_stock " + "FROM product p JOIN category c ON c.id = p.category_id " + "WHERE p.status = 1 "); List<Object> args = new ArrayList<>(); if (kw != null && !kw.isBlank()) { sql.append("AND (p.name LIKE ? OR p.barcode LIKE ?) "); args.add("%" + kw.trim() + "%"); args.add("%" + kw.trim() + "%"); // 两个占位符,必须加两次 } if (categoryId != null && categoryId > 0) { sql.append("AND p.category_id = ? "); args.add(categoryId); } sql.append("ORDER BY p.id DESC LIMIT 500"); // 兜底上限,防止误操作拉全表 try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < args.size(); i++) { ps.setObject(i + 1, args.get(i)); // JDBC 下标从 1 开始 } try (ResultSet rs = ps.executeQuery()) { List<Product> list = new ArrayList<>(); while (rs.next()) { Product p = new Product(); p.setId(rs.getLong("id")); p.setBarcode(rs.getString("barcode")); p.setName(rs.getString("name")); p.setCategoryName(rs.getString("cate_name")); p.setSalePrice(rs.getBigDecimal("sale_price")); p.setStock(rs.getInt("stock")); p.setWarnStock(rs.getInt("warn_stock")); list.add(p); } return list; } } }

LIKE '%关键词%'前面带通配符,idx_name用不上,这是明摆着的代价。小店商品量通常几百到几千条,全表扫一遍也就几毫秒,没必要为了这点性能去上全文索引。真到了上万条再考虑给name建前缀索引或者引入搜索引擎,别一上来就过度设计。LIMIT 500是给自己留的保险——有人把搜索框清空直接点查询,没有上限的 SQL 会把整个商品表拉进内存再渲染到JTable,界面能卡到你以为程序崩了。

setObject而不是setString,是因为参数列表里混着字符串和Integer,用setObject能统一处理。注意args的添加顺序和 SQL 里?的出现顺序必须完全一致,这是这种写法唯一需要小心的地方,加条件时顺手跟着写args.add,别先写完 SQL 再回头补参数。

4.2 收银结算:一个事务里必须做完的四件事

结算是整套系统里唯一真正需要事务的地方,也是最容易出问题的地方。一次点击背后要做:校验库存、写主单、写明细、扣库存、记流水。任何一步失败,前面写的全部要撤干净,否则就会出现「库存扣了但没有订单」这种查无可查的脏数据。

public String checkout(List<CartItem> cart, int payType, String cashier) throws SQLException { Connection conn = null; try { conn = JdbcUtils.getConnection(); conn.setAutoCommit(false); // 关键:关掉自动提交 // 按 product_id 升序排列,保证多线程下加锁顺序一致,避免死锁 List<CartItem> sorted = new ArrayList<>(cart); sorted.sort(Comparator.comparingLong(CartItem::getProductId)); BigDecimal total = BigDecimal.ZERO; for (CartItem it : sorted) { try (PreparedStatement ps = conn.prepareStatement( "SELECT stock, sale_price FROM product WHERE id = ? FOR UPDATE")) { ps.setLong(1, it.getProductId()); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) throw new BizException("商品不存在:" + it.getProductId()); int stock = rs.getInt("stock"); if (stock < it.getQty()) { throw new BizException("库存不足:" + it.getName() + ",剩 " + stock); } it.setPrice(rs.getBigDecimal("sale_price")); // 以库里的价格为准 } } total = total.add(it.getPrice().multiply(BigDecimal.valueOf(it.getQty()))); } // 1. 写主单,拿回自增 id String orderNo = "S" + System.currentTimeMillis(); long orderId; try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO sale_order(order_no, total_amount, pay_type, cashier) VALUES(?,?,?,?)", Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setBigDecimal(2, total); ps.setInt(3, payType); ps.setString(4, cashier); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { keys.next(); orderId = keys.getLong(1); } } // 2. 明细 + 3. 扣库存 + 4. 记流水,循环里一次做完 for (CartItem it : sorted) { try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO sale_item(order_id, product_id, product_name, price, qty) VALUES(?,?,?,?,?)")) { ps.setLong(1, orderId); ps.setLong(2, it.getProductId()); ps.setString(3, it.getName()); ps.setBigDecimal(4, it.getPrice()); ps.setInt(5, it.getQty()); ps.executeUpdate(); } try (PreparedStatement ps = conn.prepareStatement( "UPDATE product SET stock = stock - ? WHERE id = ?")) { ps.setInt(1, it.getQty()); ps.setLong(2, it.getProductId()); ps.executeUpdate(); } try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO stock_log(product_id, change_qty, type, remark) VALUES(?,?,2,?)")) { ps.setLong(1, it.getProductId()); ps.setInt(2, -it.getQty()); ps.setString(3, "销售单 " + orderNo); ps.executeUpdate(); } } conn.commit(); // 全部成功才落盘 return orderNo; } catch (Exception e) { if (conn != null) conn.rollback(); // 任一环节失败,整体回撤 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 归还连接前恢复默认 conn.close(); } } }

几个参数和写法值得抠。FOR UPDATE给被扫到的商品行加了排他锁,两个人同时买最后一件时,后到的那一个会等前一个提交,然后读到stock = 0直接抛库存不足——不加这个锁,两个事务都读到 1,最后库存变成 -1,这种错在小店里一天能出现好几次。sorted那段排序是为了避免 A 买「可乐+薯片」、B 买「薯片+可乐」时互相等锁形成死锁,加锁顺序统一是成本最低的解法。payType用int而不是字符串,是为了将来加会员卡、赊账时能直接扩枚举。BigDecimal参与金额计算时一定要用multiply(BigDecimal.valueOf(qty)),不能用new BigDecimal(qty)传 double,那等于把浮点误差又请回来了。

还有一个容易忽略的细节:finally里恢复setAutoCommit(true)。如果用连接池,这条连接会被下一次请求复用,带着autoCommit=false出去会让后面的单条UPDATE永远不提交,表现是「程序里查得到,重开就没了」,排查起来极其折磨。

4.3 库存预警与热销榜:两条最常被点开的报表 SQL

预警页的逻辑很直白,扫出所有低于阈值的在售商品,缺得越多排越前:

SELECT p.barcode, p.name, p.stock, p.warn_stock, (p.warn_stock - p.stock) AS gap FROM product p WHERE p.status = 1 AND p.stock <= p.warn_stock ORDER BY gap DESC;

热销榜要跨主单和明细两张表,还要按时间过滤,写法上把时间条件下推到主单,避免先关联再筛:

SELECT i.product_id, i.product_name, SUM(i.qty) AS total_qty, SUM(i.qty * i.price) AS total_amount FROM sale_item i JOIN sale_order o ON o.id = i.order_id WHERE o.create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY i.product_id, i.product_name ORDER BY total_amount DESC LIMIT 10;

GROUP BY里带上product_name而不是只按product_id分组,是因为明细表存的是下单时的名称快照,同一个商品改过名就会分成两组。想合并的话把product_name换成MAX(i.product_name),但在对账场景里分开显示反而更清楚——老板能看到「改名之后卖得怎么样」。DATE_SUB(CURDATE(), INTERVAL 7 DAY)从今天零点往前推七天,不含今天,如果老板要「含今天」就改成INTERVAL 6 DAY,这种边界问题最好在界面上把日期范围直接标出来,别指望用户自己算。

5. 常见问题排查:连不上库、账对不上、界面卡死这几处最容易翻车

5.1 启动阶段的连接类报错

现象:双击 jar 没反应,或者弹出「数据库配置加载失败」,控制台一片红。原因:多半是db.properties没被打进 jar(放错了目录,比如放在项目根目录而不是src/main/resources),或者mainClass写错导致根本没有入口。解决:先用java -jar market-desktop.jar在命令行里跑,把堆栈看清楚。如果是NullPointerException落在p.load(in)那一行,就是资源路径问题;如果是ClassNotFoundException: com.mysql.cj.jdbc.Driver,就是 shade 没把驱动打进去,检查依赖坐标是不是写成了旧的mysql:mysql-connector-java。

现象:报Public Key Retrieval is not allowed。原因:MySQL 8 默认用caching_sha2_password插件,客户端要拿服务端公钥,驱动默认不允许。解决:连接串加allowPublicKeyRetrieval=true。生产环境更稳妥的做法是配 SSL 或者改用mysql_native_password,本地开发图省事就直接加上。

5.2 数据一致性类问题

现象:库存扣了,但销售单列表里查不到这一单。原因:checkout里某一步抛了异常但被catch吞掉没往外抛,commit却照常执行了;或者根本没关autoCommit,每条语句各自提交,前面的成功后面失败,账就成了半截。解决:检查catch块里有没有rollback,以及有没有在finally里恢复setAutoCommit(true)。我一般的习惯是把commit()写成整个try块的最后一行,任何throw都在它之前,让代码读起来就能看出「这行之前出错就全撤」。

现象:删商品时报Cannot delete or update a parent row。原因:sale_item里有引用这个product_id的历史明细,外键拦住了物理删除。解决:商品永远不做物理删除,只把status置 0 下架。下架的商品在收银端搜不到,但在历史报表里还能查得到名字——这也是当初在明细表里存名称快照的用处之一。真要做物理清理,只能是财务归档之后整库备份再删,走单独的管理入口。

5.3 界面与编码类问题

现象:商品名在数据库客户端里正常,程序里显示成一堆问号。原因:三种可能——建库时用了utf8而不是utf8mb4、连接串少了characterEncoding=utf8、或者 Swing 默认字体不含中文字形。解决:按「库字符集 → 连接串 →UIManager字体」的顺序逐层排查。前两层用SHOW VARIABLES LIKE 'character_set%'一眼就能看出来,第三层在Main里全局设一次字体就行,别在每个JLabel上重复设置。

现象:点「查询」之后整个窗口白屏几秒,鼠标点哪都没反应。原因:查询写在按钮的actionPerformed里,而这段代码跑在事件分发线程(EDT)上,SQL 一慢,界面就跟着停摆。解决:把耗时操作挪到SwingWorker的doInBackground,结果在done()里回填表格。数据量小的时候看不出差别,一旦有人把商品表刷到几万条,这个卡顿会变成日常投诉。

6. 二次开发:三个能直接上手的改造方向

6.1 加一个「保质期」字段,要动哪些地方

这是接手这类项目后最先遇到的活,改法有固定套路,按顺序走四步就不会漏:先ALTER TABLE product ADD COLUMN expire_date DATE NULL COMMENT '保质期',再给Product实体加字段和 getter/setter,然后改ProductDao的search和insert/update三处 SQL(SELECT列、INSERT列、UPDATE的SET),最后在JTable的TableModel里加一列并处理Date到字符串的渲染。老库升级时把ALTER语句单独放一个upgrade_v2.sql,交付时按版本顺序执行,别指望客户手动改表结构。

临期预警可以复用库存预警那套逻辑,只是条件换成expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY),和status = 1组合。索引建在(status, expire_date)上,扫描量能压到很小。

6.2 报表变慢先看执行计划,别急着加缓存

日积月累之后,最先拖慢的一定是热销榜。排查顺序是固定的:EXPLAIN一下,看sale_order上的idx_time有没有被用上、type是不是ALL。如果rows很大而结果集很小,说明时间过滤没有下推,检查WHERE里是不是对create_time用了函数(比如DATE(create_time) = ...),函数会让索引直接失效,改成范围比较即可。

真到了几十万行还慢,再考虑做一张按天汇总的sales_daily表,收银成功后异步累加,报表直接查汇总表。这一步别提前做——多一张表就多一处一致性风险,小店的单量撑不起这个复杂度。

6.3 从单机走向多端共用,三条路的取舍

方案改动量适用场景主要代价
多台收银机直连同一个 MySQL只改连接串店内有局域网、机器不超过 4 台断网即停摆,需要一台常开的机器当库
本地 SQLite + 定时同步中,要写同步逻辑多门店、网络不稳定冲突合并麻烦,订单号要加门店前缀
拆出后端服务,桌面端走 HTTP大,等于重写一半后续要上手机查报表部署和运维成本翻倍

我的建议是先走第一条,把连接串做成db.properties里的可配置项,老板换机器只要改个 IP。真到了多门店再考虑第三条,而且那时候前端大概率也不是 Swing 了。

这套系统我前后整理过几遍,最大的教训是:checkout那个方法我必须每次都从头到尾读一遍才敢改动,因为金额、库存、流水三样东西全压在同一个事务里,任何一行顺序调换都可能让账对不上。后来我养成一个习惯,凡是碰结算相关的代码,改完一定手动跑三笔:正常单、库存刚好等于购买量的边界单、中途抛异常的回滚单,三个都过了才提交。希望帮到你。

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

返回列表