☰
Swing+JDBC+MySQL商品管理系统:进销存事务与库存对账实践
2026/10/27 4:27:14 网站建设 项目流程

简介:这份「小型自选商场商品管理系统」面向计算机课程设计、数据库实训与小型零售管理场景,围绕商品进货、销售、库存和供应商信息维护,以及收银台结算流程,提供一套可运行、可查阅源码的完整实现。压缩包共189个文件,约680KB,包含SQL脚本、C#窗体源码、resx资源文件、项目解决方案与配置文件,并附exe程序及mdf/ldf数据库文件,便于对照表结构、业务逻辑与界面实现。资源已有1027人学习,适合需要参考进货记录与按月统计、销售查询与日/月盘存、库存动态刷新、供应商查询和收银清单与收付款显示思路的读者,也可作为课程作业或小型管理系统的二次开发基础。

1. 从一次盘点对不上账说起:这个商品管理系统到底管什么

早班店员拿着打印出来的库存表对货架,系统显示某款饮料还有 120 瓶,货架上数来数去只有 87 瓶,差额既查不到是哪次进货记错,也查不到是哪笔销售漏登。这类小型自选商场——两三百平、几千个 SKU、三五个收银员——用 Excel 管商品,短期没问题,订单一多就开始出现账实不符。这个项目就是针对这个场景做的一套桌面端商品管理系统:商品与分类维护、供应商建档、进货入库、销售出库、库存流水和低库存预警,数据落在 MySQL 里,界面用 Java Swing 搭。适合课程设计要交完整项目、也适合小商户拿去做进销存底子的开发者;想学微服务那套的人不适合从它入手,但想把「数据库 + 商品管理系统」这条主线吃透,它的表结构和事务链路足够完整。

2. 技术选型与工程骨架:Swing + JDBC + MySQL 这套组合怎么落地

2.1 为什么是 Swing 加原生 JDBC,而不是 Web 或 ORM

先说说选型逻辑,因为这决定了后面所有代码的写法。小型自选商场的收银台往往就是一台旧电脑,装个 JDK、双击 jar 就能跑,是最省事的交付方式;如果做成 Web,商户得配 Tomcat、配端口、配防火墙,出了问题电话里根本说不清。Swing 的界面确实不算好看,但胜在控件齐全、事件模型简单,JTable 配一个 TableModel 就能撑起商品列表这种典型表格页。

数据访问层没有上 MyBatis 或 Hibernate,而是手写 DAO + PreparedStatement。原因有三个:表一共九张,SQL 全部可控;出问题时能看到完整 SQL 和参数,不用猜框架生成了什么;对刚接触数据库的开发者来说,JDBC 是绕不开的基本功,先写一遍原生事务,再去看 ORM 的@Transactional才知道它在背后做了什么。常见做法是写一个DBUtil统一拿连接,等并发上来再换成 HikariCP 连接池,接口不用改。

这里有个取舍要提前想清楚:Swing 客户端直连 MySQL,意味着数据库账号密码放在客户端配置文件里。单机自用没问题,如果商场里多台收银机共用一个库,就应该把 DAO 层抽出来做成一个薄的服务端,客户端只发请求。项目当前是单机形态,二次开发时这是第一个要评估的点。

2.2 工程目录:entity、dao、service、ui 四层各管什么

目录结构直接决定了后期改功能时会不会牵一发动全身。这个项目按职责分成五块,包名以com.market开头:

src/main/java/com/market ├── entity # 实体类,与数据库表一一对应,只有字段和 getter/setter │ ├── Product.java │ ├── Category.java │ ├── Supplier.java │ ├── PurchaseOrder.java │ ├── SaleOrder.java │ └── StockLog.java ├── dao # 数据访问,只写 SQL 和结果集映射,不写业务判断 │ ├── ProductDao.java │ ├── StockLogDao.java │ └── ... ├── service # 业务编排,事务边界在这一层 │ ├── PurchaseService.java │ ├── SaleService.java │ └── StockWarningService.java ├── ui # Swing 窗体,只负责收集输入和展示结果 │ ├── LoginFrame.java │ ├── MainFrame.java │ ├── panel/ProductPanel.java │ └── panel/SalePanel.java └── util # DBUtil、日期工具、金额格式化

分层约束只有一条:ui 不许出现 SQL,dao 不许出现JOptionPane。界面里点“保存”按钮,调用 service 的方法,service 组织多个 dao 调用并用事务包住,dao 只做单表增删改查。这条规矩在项目小的时候看不出价值,等到要给销售单加一个“整单折扣”字段时,你就知道改动只落在 service 和 entity 有多省心。

实体类不要偷懒用Map<String,Object>传数据。Swing 的表格模型、下拉框渲染都依赖明确的类型,商品价格用BigDecimal,数量用int,时间用LocalDateTime,类型定死之后,后面金额计算出错、日期格式化异常的排查成本会低很多。

2.3 跑起来的第一步:依赖、配置和连接工具类

先把依赖配好。pom.xml 里最关键是 MySQL 驱动,8.x 驱动类名是com.mysql.cj.jdbc.Driver,和 5.x 的com.mysql.jdbc.Driver不兼容,这是新手第一个翻车点:

<dependencies> <!-- MySQL 8.x 驱动,版本按本地仓库里已有的 8.x 调整 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 可选:替换 Swing 默认外观,省得自己画扁平化控件 --> <dependency> <groupId>com.formdev</groupId> <artifactId>flatlaf</artifactId> <version>3.2</version> </dependency> </dependencies>

数据库连接参数不要硬编码在 Java 里,放到src/main/resources/db.properties,换机器时只改这一个文件:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/market_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.user=root jdbc.password=123456

characterEncoding=utf8负责客户端与服务端的字符集协商,serverTimezone不写会在部分驱动版本上报时区异常,allowPublicKeyRetrieval=true是 MySQL 8 默认加密插件下的常见补丁,useSSL=false只为本地开发省掉证书配置。生产环境把 SSL 关掉不是好习惯,这一点后面避坑章节还会提。

连接工具类负责读配置、加载驱动、给连接、关资源,代码不长但每个项目都得有:

public final class DBUtil { private static String url; private static String user; private static String password; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("jdbc.driver")); // 触发驱动注册 url = props.getProperty("jdbc.url"); user = props.getProperty("jdbc.user"); password = props.getProperty("jdbc.password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } // 顺序关闭,避免 null 判断散落在每个 dao 里 public static void close(Connection conn, Statement st, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }

静态块只在类加载时执行一次,配置错了会直接抛ExceptionInInitializerError,比每次拿连接时报错更容易定位。getConnection()每次新建物理连接,在单机低频操作下够用;如果收银台每秒都在查商品,就该换成连接池,把getConnection内部改成从HikariDataSource取,方法签名保持不变。

启动顺序建议固定成:先建库建表并插入一条管理员账号,再确认db.properties的库名和端口,最后才运行主类。主类里调用FlatLightLaf.setup()换外观,然后打开登录窗体,这一步不用放在main里做业务,只做界面初始化。

3. 数据库设计:九张表撑起商品、进货、销售和库存预警

3.1 九张表的职责划分与字段取舍

表设计是这类系统的地基,界面可以后期美化,表结构改起来却要动数据。项目用九张表:t_user管登录账号和角色,t_category管商品分类,t_supplier管供应商,t_product管商品主数据,t_purchase_order和t_purchase_item是进货主单与明细,t_sale_order和t_sale_item是销售主单与明细,t_stock_log记录每一次库存增减。

主单和明细拆开的原因很实际:一次进货可能包含十几种商品,主单存供应商、总金额、操作人、时间,明细存每个商品的单价和数量。合并成一张表会导致供应商信息重复十几遍,改一个电话号码要更新多行,很容易改漏。销售单同理,而且销售明细还要记录成交单价,因为商品的售价会变,历史单据不能跟着变。

商品表里的stock字段是冗余设计——理论上可以每次用进货总量减销售总量算出来。但首页看板要实时显示每个商品的库存,每次打开都去t_stock_log做聚合,数据量上千条之后明显变慢。所以t_product.stock保存当前库存,t_stock_log保存变动历史,两者在同一个事务里更新,用流水表来对账。这个冗余是有意为之,不是设计失误。

用户表别存明文密码。常见做法是存SHA-256加盐后的十六进制串,登录时把用户输入同样算一遍再比对。项目里如果已经存了明文,二次开发第一件事就是写一个迁移脚本把历史密码批量转掉,同时给t_user加一个salt字段。

3.2 商品表与库存流水:两个最容易设计错的细节

先看商品表的核心字段:

CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL COMMENT '条码,扫码枪输入', name VARCHAR(64) NOT NULL COMMENT '商品名称', category_id INT NOT NULL COMMENT '分类外键', supplier_id INT DEFAULT NULL COMMENT '默认供应商', purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '进价', sale_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '当前库存', warn_stock INT NOT NULL DEFAULT 5 COMMENT '低库存阈值', unit VARCHAR(8) NOT NULL DEFAULT '件', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode), KEY idx_category (category_id), KEY idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第一个容易错的地方是金额类型。用double存价格,几毛钱的商品累加几十次就会出现19.900000000000002这种结果,对账时很难解释。统一用DECIMAL(10,2),Java 侧用BigDecimal,做加减乘除时指定RoundingMode.HALF_UP。

第二个容易错的地方是条码唯一索引。自选商场的商品条码可能重复录入,比如同一条码对应不同规格,如果不加唯一约束,扫码查商品会查出两条,收银员不知道该选哪个。真遇到一条码多规格的场景,应该拆成“条码 + 规格”联合唯一,而不是简单去掉约束。

库存流水表负责可追溯:

CREATE TABLE t_stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, change_type TINYINT NOT NULL COMMENT '1进货 2销售 3盘点调整 4退货', change_qty INT NOT NULL COMMENT '正数增加,负数减少', before_qty INT NOT NULL COMMENT '变动前库存', after_qty INT NOT NULL COMMENT '变动后库存', ref_id BIGINT DEFAULT NULL COMMENT '关联的进货单或销售单 id', operator_id INT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

流水表里同时存变动前和变动后库存,看似冗余,排查时非常有用。某天发现账实不符,按商品查流水,逐条比对before_qty + change_qty是否等于after_qty,哪一条断了就是哪次操作出了问题,比只存一个变动量强得多。change_type用数字而不是字符串,是为了索引效率和枚举统一,Java 侧配一个枚举类做映射。

3.3 索引与低库存预警:让首页看板不卡

商品列表页通常有三个查询入口:按条码精确查、按名称模糊查、按分类筛选。条码上的唯一索引天然支持精确匹配;idx_name支持LIKE '可乐%'这种前置匹配,但LIKE '%可乐%'用不上索引,数据量上万后会全表扫。小商场的商品数量通常在几千条以内,全表扫还能接受,真要优化就上全文索引或者把搜索转成拼音首字母字段,这是二次开发可以做的点。

低库存预警的 SQL 本身很简单:

SELECT id, barcode, name, stock, warn_stock, unit FROM t_product WHERE status = 1 AND stock <= warn_stock ORDER BY (warn_stock - stock) DESC;

真正要注意的是执行频率。如果主界面每隔几秒轮询一次,几千条数据加上排序,会持续占用连接。合理做法是进入主界面时查一次,进货和销售完成后再触发一次刷新;或者干脆在 service 层维护一个内存缓存,写操作后更新缓存,界面只读缓存。stock和warn_stock上都不要建单独索引,区分度太低,MySQL 优化器多半会走全表扫,建了反而增加写入开销。

另外提醒一句,status字段的取值要在 Java 侧定义成常量或枚举,不要在 SQL 里散落status = 1、status = 0这样的魔法数字。项目里如果已经散落了,改造时统一替换,否则以后加一个“待审核”状态,全项目搜= 1能搜出一堆误伤。

4. 核心链路拆解:一条商品记录从入库到出库要过哪些代码

4.1 登录与权限:一条 PreparedStatement 挡掉注入

登录窗口收两个输入框,账号和密码。拼接 SQL 是这类项目最常见的翻车点,"select * from t_user where username='" + name + "'"这种写法,输入' or '1'='1就能绕过。正确写法是占位符:

public User login(String username, String rawPassword) throws SQLException { String sql = "SELECT id, username, password, salt, role FROM t_user WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 参数绑定,不做字符串拼接 try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; // 用户不存在,统一返回 null,不提示具体原因 String salt = rs.getString("salt"); String expected = rs.getString("password"); String actual = DigestUtils.sha256Hex(rawPassword + salt); if (!expected.equalsIgnoreCase(actual)) return null; User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setRole(rs.getString("role")); // ADMIN / CASHIER return u; } } }

PreparedStatement把参数当数据传给数据库,不参与 SQL 语法解析,注入面直接消失。密码比对用equalsIgnoreCase是因为十六进制串大小写不敏感,但更稳妥的做法是统一转小写再比。登录失败时不要区分“用户不存在”和“密码错误”,避免被枚举账号;界面上只提示“账号或密码不正确”。

角色字段决定主界面显示哪些菜单。收银员只看到销售和查询,管理员才能改进货价和删除商品。权限判断放在 ui 层做菜单可见性,同时 service 层也要再校验一次,因为 Swing 的菜单可以被代码直接调用,不能只靠界面隐藏。

4.2 商品列表:分页查询与 TableModel 的接法

商品管理面板是一个JTable,上方有搜索框和分类下拉。数据量不大时一次性查全量也能跑,但养成带分页的习惯没坏处:

public List<Product> findByPage(String keyword, Integer categoryId, int page, int size) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT p.*, c.name AS category_name FROM t_product p " + "LEFT JOIN t_category c ON p.category_id = c.id WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.isBlank()) { sql.append("AND (p.name LIKE ? OR p.barcode = ?) "); params.add("%" + keyword.trim() + "%"); params.add(keyword.trim()); } if (categoryId != null) { sql.append("AND p.category_id = ? "); params.add(categoryId); } sql.append("ORDER BY p.id DESC LIMIT ? OFFSET ?"); params.add(size); params.add((page - 1) * size); // 省略:设置参数、遍历 ResultSet 映射成 List<Product> }

WHERE 1=1是为了后面拼接条件时不用判断“是不是第一个条件”,代价可以忽略。LEFT JOIN分类表把分类名一起查出来,界面上就不用为每一行再查一次数据库,避免 N+1 查询。keyword同时匹配名称和条码,扫码枪输入的内容直接走条码精确匹配。

界面侧继承AbstractTableModel,把List<Product>塞进去,getValueAt按下标取值,金额列返回格式化后的字符串。这里有个细节:JTable默认所有单元格可编辑,商品列表应该只读,重写isCellEditable返回false,否则用户双击就能改库存,改完还不落库,造成界面和数据库不一致的假象。刷新时调用fireTableDataChanged(),不要在 EDT 之外直接操作模型。

4.3 进货入库:一个事务包住四步写操作

进货是整套系统里最需要事务的地方。一次入库要写主单、写明细、加库存、记流水,四步里任何一步失败都必须整体回滚,否则会出现“单子有了库存没加”或者“库存加了流水没有”的脏数据:

public void purchase(PurchaseOrder order, List<PurchaseItem> items, int operatorId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 写主单,拿回自增 id long orderId = purchaseOrderDao.insert(conn, order); // 2. 逐条写明细,同时加库存 for (PurchaseItem item : items) { item.setOrderId(orderId); purchaseItemDao.insert(conn, item); int before = productDao.getStockForUpdate(conn, item.getProductId()); productDao.increaseStock(conn, item.getProductId(), item.getQty()); stockLogDao.insert(conn, item.getProductId(), 1, item.getQty(), before, before + item.getQty(), orderId, operatorId); } conn.commit(); } catch (SQLException e) { if (conn != null) conn.rollback(); // 任一步失败整体回滚 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }

四个 dao 方法都接收同一个Connection,这是事务能生效的前提。如果每个 dao 内部各自DBUtil.getConnection(),那就是四个独立事务,回滚不了。getStockForUpdate里带SELECT ... FOR UPDATE,锁住商品行,防止两个人同时进货时读到同一个变动前库存。

参数上,数量必须大于零,进价必须大于等于零,这些校验放在 service 方法开头,校验不过直接抛业务异常,不要等到 SQL 报错。主单总金额由明细累加得出,不要相信界面传进来的总额,避免前端算错导致账不平。

4.4 销售出库:用条件更新防超卖

销售比进货多一个约束:库存不够不能卖。最直觉的写法是先查库存、判断够不够、再扣减,三步分开在并发下会出问题——两个收银员同时卖最后一件,都查到库存为 1,都判断通过,最后扣成 -1。解决办法是把判断塞进 SQL:

public boolean sale(SaleOrder order, List<SaleItem> items, int operatorId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); long orderId = saleOrderDao.insert(conn, order); for (SaleItem item : items) { // 条件更新:库存足够才扣,返回受影响行数 int affected = productDao.decreaseStockIfEnough( conn, item.getProductId(), item.getQty()); if (affected == 0) { throw new SQLException("商品 " + item.getProductId() + " 库存不足"); } int after = productDao.getStock(conn, item.getProductId()); stockLogDao.insert(conn, item.getProductId(), 2, -item.getQty(), after + item.getQty(), after, orderId, operatorId); saleItemDao.insert(conn, item); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }

对应的 SQL 是UPDATE t_product SET stock = stock - ? WHERE id = ? AND stock >= ?。数据库对同一行的更新是串行的,第二条语句执行时看到的已经是扣减后的库存,条件不满足就返回 0 行,业务层据此抛异常回滚整单。这比在 Java 里加synchronized可靠,因为锁只在单机有效,多收银台连同一个库时完全失效。

销售明细要单独存成交价,不能查询时再关联商品表取当前售价。商品调价之后,历史单据的金额必须保持不变,这是财务数据的基本要求。退货场景可以复用同一套逻辑,把change_type改成 4,库存做加法,金额记负数,主单类型区分销售和退货即可。

5. 避坑与排查:连接、乱码、事务、卡界面这五类问题我都遇到过

5.1 驱动类报 ClassNotFoundException

现象:项目编译通过,一运行就抛java.lang.ClassNotFoundException: com.mysql.jdbc.Driver,或者提示驱动版本不匹配。

原因:MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,很多老教程还写着 5.x 的com.mysql.jdbc.Driver;另一种情况是 pom 里引了驱动但打包时没带进去,java -jar运行时类路径里没有这个 jar。

解决:先确认驱动版本和类名对应,8.x 一律用com.mysql.cj.jdbc.Driver。如果是打包问题,用 Maven 的 shade 或 assembly 插件打一个包含依赖的 fat jar,并在MANIFEST.MF里写对Main-Class。实在不想折腾打包,就在 IDE 里直接运行主类,先排除代码问题再排查构建问题。

5.2 中文商品名变成问号

现象:界面上录入“农夫山泉”,数据库里查出来是???,或者排序结果乱序。

原因:三个环节的字符集不一致——数据库、表、连接。建库时用了默认的latin1,连接 URL 里又没写characterEncoding,驱动按服务端默认字符集发送,中文就丢了。

解决:建库语句明确写DEFAULT CHARSET=utf8mb4,排序规则用utf8mb4_general_ci;连接 URL 带上useUnicode=true&characterEncoding=utf8;已经建好的表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4迁移。注意utf8在 MySQL 里最多三字节,存 emoji 或生僻字会失败,新项目一律上utf8mb4。改完之后重启应用,别在旧连接上验证,连接池里的旧连接还带着老字符集。

5.3 事务不回滚,库存加了单子没入库

现象:进货时明细插入报错,界面提示失败,但商品库存已经加上去了,主单表里却没有记录。

原因:dao 方法内部各自拿了新连接,setAutoCommit(false)设在 A 连接上,实际执行 update 的是 B 连接,事务根本没覆盖到这些语句;另一种是 catch 块里吞掉了异常,没有调用rollback()。

解决:事务方法里所有 dao 调用必须共享同一个Connection,把连接作为参数往下传。catch 里先rollback()再往外抛,finally 里恢复setAutoCommit(true)再关闭。排查时在commit和rollback各打一行日志,看看到底走了哪条分支。还有一个隐蔽点:MySQL 的 MyISAM 引擎不支持事务,建表时必须显式写ENGINE=InnoDB。

5.4 点查询按钮界面卡死

现象:商品数量多了之后,点“查询”按钮窗口直接无响应,鼠标转圈,过几秒才恢复。

原因:数据库查询写在了按钮的actionPerformed里,而 Swing 的事件回调运行在 EDT(事件分发线程)上,耗时操作阻塞 EDT,界面就无法重绘和响应。

解决:把查询放到SwingWorker的doInBackground里执行,结果在done()里回到 EDT 更新表格模型。done()方法本身运行在 EDT,可以安全操作组件。别在doInBackground里直接改TableModel,那是另一个线程,轻则界面错乱,重则抛异常。数据量特别大时再加一个加载中的遮罩或进度条,用户体验会好很多。

5.5 库存对不上,流水却查不出问题

现象:商品表里的stock和库存流水累计出来的数字不一致,但每条流水的before_qty + change_qty都等于after_qty。

原因:有人直接改过t_product.stock,比如在数据库客户端里手工修数据,绕过了流水表;或者某次操作只更新了商品库存,忘了写流水,事务范围没覆盖到流水插入。

解决:写一个对账 SQL,按商品分组汇总流水,和商品表当前库存比对,差值为非零的就是问题商品。短期用盘点功能修正,change_type记为 3,把差额补成一条流水,保证账实一致。长期要把“改库存必须写流水”固化到 service 层,任何直接更新stock的代码都要过 code review。我一般还会给t_stock_log加一个唯一约束,同一单据同一商品只允许一条流水,防止重试导致重复记账。

6. 二次开发:给系统加促销价和 CSV 报表导出的改造路径

拿到这套代码之后,最常被提出的需求是“能不能做活动价”和“能不能把销售导出来给会计”。这两件事一个动数据模型,一个动导出逻辑,正好覆盖二次开发的两类典型改动。

促销价的改造不建议直接改t_product.sale_price,那样历史订单金额会跟着变。更稳的做法是加一张t_promotion表,字段包括product_id、promo_price、start_time、end_time、status,结算时按当前时间查生效的促销记录,取不到就回落原价。销售明细仍然存实际成交价,这样促销结束、价格恢复,历史单据不受影响。改造顺序是:先建表并插入测试数据,再在SaleService里加一个resolvePrice(productId, now)方法,最后改收银界面的价格显示。每步都单独验证,别一次改完再测。

报表导出用原生 IO 写 CSV 就够了,不引第三方库也能跑:

public void exportSales(LocalDate from, LocalDate to, File target) throws IOException { String sql = "SELECT o.id, o.create_time, p.name, i.qty, i.price, " + "(i.qty * i.price) AS amount, u.username " + "FROM t_sale_order o " + "JOIN t_sale_item i ON i.order_id = o.id " + "JOIN t_product p ON p.id = i.product_id " + "JOIN t_user u ON u.id = o.operator_id " + "WHERE o.create_time BETWEEN ? AND ? ORDER BY o.id"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); BufferedWriter writer = Files.newBufferedWriter(target.toPath(), StandardCharsets.UTF_8)) { ps.setTimestamp(1, Timestamp.valueOf(from.atStartOfDay())); ps.setTimestamp(2, Timestamp.valueOf(to.plusDays(1).atStartOfDay())); writer.write('\uFEFF'); // BOM,防止 Excel 打开中文乱码 writer.write("单号,时间,商品,数量,单价,金额,收银员\n"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { writer.write(String.join(",", rs.getString("id"), rs.getString("create_time"), escapeCsv(rs.getString("name")), rs.getString("qty"), rs.getString("price"), rs.getString("amount"), rs.getString("username"))); writer.newLine(); } } } catch (SQLException e) { throw new IOException("导出失败: " + e.getMessage(), e); } }

这段代码有三个容易忽略的点。时间区间要用左闭右开,to.plusDays(1)再取当天零点,否则截止日当天的单据会漏掉。写 UTF-8 BOM 是为了让 Excel 正确识别编码,不加的话中文列名会显示成乱码,这是很多人导出后反馈“文件坏了”的真实原因。商品名里可能包含逗号,直接String.join会撑破列,需要写一个escapeCsv把含逗号、引号、换行的字段用双引号包起来,内部双引号转义成两个。

验证方式建议分三层:先用一条已知数据手工算金额,和导出结果比对;再构造跨天的数据,确认边界日期没漏;最后用 Excel 打开检查中文和金额列格式。改造完成后跑一遍完整的“进货—销售—退货—导出”流程,看库存流水是否连续,对账 SQL 是否返回空结果。

从那以后我每次接这类进销存项目,都先把库存流水表和对账 SQL 建好,再动界面,哪怕需求方催着看效果。界面能演示只是开始,账能对上才算交付。希望帮到你。

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

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

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

立即咨询