简介:一份基于Java Swing的进销存管理系统完整源码包,面向计算机相关专业毕设、课程设计以及希望了解桌面管理信息系统开发的初学者。系统涵盖信息管理(客户、商品、供应商)、业务管理(进货单、销售单)、库存管理(库存盘点)、查询统计(客户、商品、供应商及销售、入库查询)和系统管理(操作管理)五大模块,为企业库存、销售与进货数据提供管理及分析支持;配套SQL Server 2000数据库文件(含.mdf/.ldf),可在MyEclipse中直接导入运行,适合需要兼容早期数据库环境的项目。资源共325个文件,压缩包仅4.47MB,主要包含169个class字节码、75个java源文件、56个png界面图、7个doc设计文档,以及jar依赖库、properties配置、SQL数据库文件等,结构便于按功能模块检索与对照学习。已有599人学习下载。借助完整源码与设计文档,读者可理清Swing界面层、DAO数据访问层的调用关系,掌握订单录入、库存盘点、条件查询等典型功能的实现方式,并可参考文档完成数据库表设计与扩展,适合作为实战练手、答辩演示及二次开发基础。
1. Java Swing 进销存管理系统源码:老项目里的高密度 Java 综合练习
个人云盘和技术分享群里,Java Swing 进销存管理系统源码(附相关设计文档)是老面孔。它不像 Spring Boot 上来就要面对一堆容器概念,也不像算法题那样脱离业务;解压 zip 后通常是一个 Java SE 工程、几份 SQL 脚本和 Word 版设计文档。把这套源码跑通,等于把 Java 基础语法、JDBC、Swing 布局、MySQL 事务和分层思想完整过了一遍,不少准备 java 面试题的人拿它当综合案例复盘。我拿到这类 zip 的习惯是:先找 ER 图,再找建表脚本,最后打开主窗体类运行。只要三步能对上,后面改功能、盘点、导报表都顺;对不上,多半要先查数据库连接或版本差异,否则连登录页都出不来。
2. 进销存的核心是库存流水,不是库存表当前值
2.1 进销存管理系统的四个基础角色
无论源码界面做得多炫,数据库表有多少张,进销存领域总绕不开四个基础角色:商品、往来单位、仓库和结算账户。商品是进货和销售的标的,往来单位同时承担客户与供应商两个身份,仓库记录货品放在哪里,结算账户处理应收应付。老设计文档常把客户和供应商拆成 customer、supplier 两张表,但从业务上完全可以合并成 partner + type 的结构。表结构不同,界面层代码的组织方式也不同,看源码前先用角色去对表,比硬背字段快。
| 角色 | 核心字段 | Java 层常见对象 |
|---|---|---|
| 商品 | product_id, product_name, spec, purchase_price, sale_price | Product |
| 往来单位 | partner_id, partner_name, type | Partner |
| 仓库 | warehouse_id, warehouse_name, address | Warehouse |
| 结算账户 | account_id, account_name, balance | Account |
这四个角色在界面上分属“基础数据”菜单,但每张业务单据都离不开它们。判断一套源码设计得是否清晰,就看业务单据表是直接存字段冗余,还是只存 id。早期进销存为了查询快,会在 order 表里同时存商品名称、单位、价格;这样商品改名后老单据也跟着变,审计对不上。设计文档如果强调“单据行冗余商品快照”,那对应的 Java 实体也要加对应的快照字段,别只看到 product_id。
2.2 从 ER 图和建表 SQL 拆出三张核心表
设计文档里的 ER 图会画出 product 与 stock 一对一,也有 purchase_main、purchase_detail 与 product 的多对多。落库时不一定建外键,但字段关联必须一致。我一般先重建这三张表,把库存流水独立出来,这样后续所有报损、退货、盘点都有地方还原。
-- 商品基础表:状态位让“停用商品”仍然可查历史 DROP TABLE IF EXISTS product; CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NULL, product_name VARCHAR(128) NOT NULL, spec VARCHAR(64) DEFAULT '', unit VARCHAR(16) DEFAULT '件', purchase_price DECIMAL(10,2) DEFAULT 0.00, sale_price DECIMAL(10,2) DEFAULT 0.00, status TINYINT DEFAULT 1, UNIQUE KEY uk_barcode (barcode) ); -- 库存表:只保存当前结存,不要在这里手工加加减减 DROP TABLE IF EXISTS stock; CREATE TABLE stock ( product_id INT PRIMARY KEY, warehouse_id INT NOT NULL DEFAULT 1, quantity INT NOT NULL DEFAULT 0, min_stock INT DEFAULT 0, max_stock INT DEFAULT 0 ); -- 库存流水表:每一笔进出都留痕,对账全看这张表 DROP TABLE IF EXISTS stock_flow; CREATE TABLE stock_flow ( flow_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, warehouse_id INT NOT NULL DEFAULT 1, ref_type TINYINT NOT NULL COMMENT '1-进货入库,2-销售出库,3-盘点调整', ref_no VARCHAR(32) NOT NULL, direction TINYINT NOT NULL COMMENT '1-入库,-1-出库', quantity INT NOT NULL, before_qty INT DEFAULT 0, after_qty INT DEFAULT 0, biz_date DATETIME DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(255), KEY idx_flow_biz_date (biz_date), KEY idx_flow_ref_no (ref_no) );这段 SQL 的关键参数在两个地方。direction用 1 和 -1 而不是 in/out,查询时可以直接SUM(quantity * direction)算出累计变动量,省去 case when。before_qty和after_qty虽然是冗余,但做库存审计时能很快看出某张单据把结存从多少改成了多少;如果源码里的流水表没有这两个字段,也可以先不补,等对账出问题再加。注意ref_no建议建索引,因为它会出现在每一条对账 SQL 的关联条件里。建完表后,用SHOW CREATE TABLE stock_flow和设计文档里的数据字典对一遍,字段注释不一致的地方多半就是文档滞后的信号。
2.3 库存表为什么不能直接按加减来维护
很多下载到的源码里,采购入库就是一句UPDATE stock SET quantity = quantity + ?,销售出库就是减。单看没问题,但一旦出现退货、订单作废、分仓调拨,就需要同时改好几张表,稍漏一行,期末盘点就对不上。更合理的流程是先保存单据主表和明细行,再向 stock_flow 插入一条流水,最后用事务更新 stock 表。界面上的“库存回滚”其实就是删掉流水再重新计算,前提是流水表里所有进出都有ref_no和biz_date。
这里补充一个常见现象:源码的建表脚本里没有 stock_flow,只有 product 和 stock。这种系统做 demo 可以,用在真实门店会非常难受,因为无法回答“这批货是什么时候进的、当时多少钱”。所以从设计文档的 ER 图上先找“流水”或“日志”字样的表,如果能找到,说明作者考虑了账实一致;找不到,你后续二次开发的第一件事就是补流水表,再把原来的库存更新语句改成事务。
3. 在本地把 Java Swing 进销存管理系统源码跑起来的最小路径
解压 zip 后,先看目录结构。一个能直接跑的源码包通常包含 src、sql、lib、doc 和 README.txt 五个部分;lib 里放着 mysql-connector jar,sql 里是建库脚本,doc 就是标题里说的相关设计文档。如果 lib 是空的,说明作者默认你手动下载驱动,运行前需要先解决依赖,否则会在点击“查询”按钮的那一刻才抛出NoClassDefFoundError。
3.1 先配 JDK 与 MySQL,版本差异明确列出
Swing 项目大多数出生在 JDK 8 时代,我建议本地直接装 JDK 8,而不是装最新版。高版本不是不能跑,但老源码中用-encoding UTF-8和某些内部 API 在高版本会被限制,排错成本反而高。装完后在命令行确认。
java -version javac -version mysql --version正常情况下第一行会显示java version "1.8.0_...",第三行显示 MySQL 的版本号。如果 java 后面报javac not found,就是 Path 没有指向 JDK 的 bin 目录;这个问题在 windows 上搜“java 环境变量配置详细教程”就有一堆答案,核心是新建JAVA_HOME并把%JAVA_HOME%\bin加到 Path。MySQL 用 5.7 或 8.0 都行,但连接驱动类名不同,这一点在 3.2 里会再强调。
3.2 创建数据库并导入 sql 脚本
业务库命名常见为 jxc、shop、erp,先看 install 脚本里的CREATE DATABASE语句。如果不一致,直接把库名改成脚本里的名字,或者在建立库时保持统一。
# 新建业务库,utf8mb4 比 utf8 更稳 mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS jxc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入脚本,路径按实际解压位置调整 mysql -uroot -p jxc < sql/jxc.sql这里有个命令行习惯:-p后面不要直接跟密码,留在交互提示里输入,防止密码写进 shell 历史。导入完后登录 MySQL 执行USE jxc; SHOW TABLES;,如果表数量不等于文档中的 ER 图数量,先检查脚本是不是分段执行,特别是purchase_detail这种后来追加的表,可能在第二份 sql 文件里。
3.3 在 DBUtil 里确认四个连接点
大多数老源码用一个DBUtil.java类集中管理连接。打开它改四件事:driver、url、username、password。
// DBUtil.java 常见写法,改成你本地的实际值 public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/jxc?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }URL 里的useSSL=false是本地开发标准配置,避免 MySQL 8 的 SSL 证书警告;serverTimezone=Asia/Shanghai负责解决 CST 时区歧义;characterEncoding=utf8配合建库时的 utf8mb4,中文显示不乱码。特别提醒:如果你用 MySQL 8,驱动类必须是com.mysql.cj.jdbc.Driver;MySQL 5.7 的压缩包可能还是com.mysql.jdbc.Driver,直接切换版本即可,不用改 jar 包。
3.4 编译运行主窗体和启动异常对照表
在 IDE 里运行前,先把 lib 目录右键添加到项目依赖,再找到主类。主类名一般是LoginFrame或MainFrame,包名从src/com/xxx路径反推。命令行方式也可以跑,尤其适合跨环境验证:
# 列出所有 java 文件并编译到 out 目录 javac -encoding UTF-8 -cp "lib/*" -d out $(find src -name "*.java") # Windows 下用分号分隔 classpath java -cp "out;lib/*" com.jxc.main.LoginFrame # Linux/macOS 下用冒号 java -cp "out:lib/*" com.jxc.main.LoginFrame第一行里的find src -name "*.java"是 Linux/macOS 语法,Windows 可直接用 IDE 编译,不必纠结。如果启动时报错,对照下面这张表能快速缩小范围。
| 启动阶段报错 | 原因定位 | 处理方式 |
|---|---|---|
ClassNotFoundException: com.mysql.jdbc.Driver | 驱动 jar 没有加入 classpath | 把 mysql-connector jar 放 lib 并重新导入依赖 |
Access denied for user 'root'@'localhost' | DBUtil 账号密码不对 | 先在命令行登录 MySQL,确认 root 的密码和允许的 host |
Unknown database 'jxc' | 建库不成功或库名不一致 | 重新执行建库和导入,再用SHOW TABLES验证 |
Could not find or load main class | 主类全限定名写错 | 看编译输出的包名,改成真实路径 |
| 按钮点击后中文问号 | 编译时缺-encoding UTF-8 | 加编码参数,并把窗体字体设置为宋体 |
这一章解决了“跑起来”,但运行只是第一步。真正难的是后续改需求,比如加一个采购员字段,很多人不知道该动 table、DAO 还是 Swing 界面,下一章就从设计文档的顺序入手。
4. 相关设计文档的正确打开方式:先读模块边界,再读 SQL
4.1 一张模块划分图框定源码包里的类
老项目的设计文档比 Spring Boot 的接口文档更有参考价值,因为它通常从模块图开始。最常见的进销存模块划分是:系统管理、基础数据、采购管理、销售管理、库存管理、报表查询。六个模块直接对应源码里的六个包名。
| 模块 | 典型相关类 | 对应数据表 |
|---|---|---|
| 系统管理 | UserDao, RoleDao | sys_user, sys_role |
| 基础数据 | ProductDao, PartnerDao | product, partner |
| 采购管理 | PurchaseMainFrame, PurchaseDetail | purchase_main, purchase_detail |
| 销售管理 | SaleMainFrame, SaleDetail | sale_main, sale_detail |
| 库存管理 | StockDao, StockFlowDao | stock, stock_flow |
| 报表查询 | ReportFrame, SaleReportDao | sale_report_view |
看源码时先用模块图定位:要改商品资料,就去基础数据包找 Product 相关类;要查库存流水,去库存管理包找 StockFlow。不要在MainFrame一个文件里从登录到处翻几百行事件监听,那是没有包结构的早期代码才做的事。如果文档里的模块图和源码包名差一级,以包名为准,但要在文档的第一页标注差异,方便后来人。
4.2 用 ER 图反查数据字典,逐列对齐
设计文档里的 ER 图通常只是粗粒度关系,数据字典才是逐字段描述。问题是很多 Word 数据字典没有更新,与 sql 脚本不一致。常见做法是用 SQL 直接查看真实表结构,再回到设计文档里标记。
-- 查看销售主表的字段、类型、注释 SHOW FULL COLUMNS FROM sale_main; -- 查看销售明细表上的索引,判断关联查询会不会全表扫描 SHOW INDEX FROM sale_detail;SHOW FULL COLUMNS会输出字段名、类型、是否为空、默认值和注释,注释是设计文档里最重要的部分。遇到文档里叫price,代码里叫sale_price也不要慌,先确认 ER 图中是哪一张表,再以数据库脚本为基准生成 DAO 层代码,没有必要人肉手动改所有字段名。
4.3 用时序图理解 Swing 事件回调
很多 Swing 源码让人头晕,是因为不知道“按下保存按钮后,到底先调了谁”。设计文档的时序图会把这个过程画出来:用户点击按钮 -> 窗口监听器 -> 业务服务 -> DAO -> 数据库。落到代码就是下面这个顺序。
// 保存销售单的按钮事件,对应时序图里的两条消息 saleFrame.addSaveListener(e -> { saleService.createSale(conn, saleMain, saleDetailList); stockService.createOutFlow(conn, refNo, saleDetailList); });同样一个操作,如果面试时用三步来描述:校验主表数据、插入明细、更新库存,会比直接背一段 SQL 清楚。我在二开时遇到看不懂的代码,会在这个方法上打断点,跑一次再画一个简化时序图;这个自画的过程比读十遍源码更有效。
4.4 把测试用例直接当成验收清单
设计文档最后几页通常有测试用例,常见的有“新增商品成功”“采购入库成功”“销售出库后库存减少”。文字看着简单,但它正好是完整的验收路径。我一般会按顺序手工跑一遍,并且在每步记录界面上的库存数,最后用 SQL 对总表,看有没有出现界面上成功但库里少流水的情况。这一步能同时检验源码和设计文档是否一致,也为后面的正式上线留一份底稿。
5. Java Swing 进销存源码里值得扣出复用的代码片段
5.1 用 AppContext 保存登录信息,而不是到处传 User
Swing 没有 Spring 容器,不能随手@Autowired UserService,所以登录信息往往被塞在各种不变量里。最常见的问题是一个面板要用当前操作员,就得从主窗口一层层传递,改了构造器还要改调用处。更简单的方式是定义一个全局上下文类,把用户信息放在静态变量里。
// 单机版桌面系统可接受,不需要线程级隔离 public class AppContext { private static User currentUser; private AppContext() {} public static void login(User user) { currentUser = user; } public static User getCurrentUser() { return currentUser; } public static void logout() { currentUser = null; } }这个类的好处是保存单据时直接AppContext.getCurrentUser().getUserId(),不需要在销售单、入库单单据类里再加 userId 字段。要注意的是静态变量会一直存在到 JVM 退出,每次重新登录时一定先调logout()防止上一个用户残留。如果以后要改成 Web 版,这个类就不能用,需要换成 Session 或 ThreadLocal;但在 Swing 进销存源码这个规模下是合适的。
5.2 用 AbstractTableModel 封装 JTable,别直接操作 ResultSet
很多旧代码用ResultSet列名,循环model.addRow(new Object[]{...})填充 JTable;这在数据量小的时候看不出问题,一旦需要排序、过滤、修改单元格,就会把表格和业务逻辑混在一起。更稳的做法是自定义表格模型。
// 表格模型从 List<Product> 取数据,与 ResultSet 解耦 public class ProductTableModel extends AbstractTableModel { private final String[] columns = {"商品ID", "商品名称", "规格", "库存", "售价"}; private final List<Product> products = new ArrayList<>(); public void setProducts(List<Product> list) { products.clear(); products.addAll(list); fireTableDataChanged(); } @Override public int getRowCount() { return products.size(); } @Override public int getColumnCount() { return columns.length; } @Override public String getColumnName(int c) { return columns[c]; } @Override public Object getValueAt(int row, int col) { Product p = products.get(row); switch (col) { case 0: return p.getProductId(); case 1: return p.getProductName(); case 2: return p.getSpec(); case 3: return p.getStock(); case 4: return p.getSalePrice(); default: return null; } } }使用时JTable table = new JTable(new ProductTableModel()),刷新数据就一行model.setProducts(productDao.queryAll())。关键点是fireTableDataChanged():告诉 JTable 整个数据集合变了,UI 会重新调用getValueAt,不需要table.revalidate()。如果列表里只有一行变化,可以用fireTableRowsUpdated(row, row)减少重绘范围。如果还想让“库存低于下限”的行显示红色,再单独实现TableCellRenderer,这与表格模型是两块独立职责。
5.3 销售开单是天然的事务边界,DAO 必须共用 Connection
销售开单最少涉及三件事:写销售单头、写销售单明细、减库存并记流水。任何一个步骤失败,都不能留下只有半张单。常见错误是每个 DAO 方法内部自己getConnection(),结果三个 DAO 各连各的库,事务根本没有生效。
// 正确的做法:Connection 只获取一次,向下传参 public void createSale(SaleMain main, List<SaleDetail> lines) throws SQLException { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 先插入主表,回填自增主键 int mainId = saleMainDao.insert(conn, main); for (SaleDetail line : lines) { line.setMainId(mainId); saleDetailDao.insert(conn, line); // 记录库存流水,再更新库存结存 stockFlowDao.addFlow(conn, line.getProductId(), -line.getQuantity()); int ok = stockDao.changeStock(conn, line.getProductId(), -line.getQuantity()); if (ok == 0) { throw new SQLException("库存不足:" + line.getProductName()); } } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }这个方法的参数顺序值得说明。DAO 方法全部接受 Connection 作为第一个参数,目的是让它们共用同一个事务;stockDao.changeStock的 SQL 最好写成UPDATE stock SET quantity = quantity + ? WHERE product_id = ? AND quantity + ? >= 0,这样在数据库层面也拒绝负库存。最后finally里把 autoCommit 恢复为 true,避免连接池复用下一条 SQL 继承false状态。
| 循环步骤 | 必需写入的表 | 失败时的后果 |
|---|---|---|
| step 1 | sale_main | 无主单,明细无法归属 |
| step 2 | sale_detail | 商品明细缺失,报表数量偏大 |
| step 3 | stock_flow, stock | 库存流水断链,对账不平 |
这张表也解释了为什么销售单不能只写在界面上:单头、明细、库存流水必须被同一个 commit 保住,缺一步,整个系统的可信度就崩了。
5.4 动态 SQL 的参数绑定,比字符串拼接更值得养成习惯
进销存查询界面的筛选条件通常是商品名称模糊、条形码、分类、库存区间。老代码里为了省事,直接用字符串拼接关键字,这在单机版看着没风险,但一个单引号就能让 SQL 结构变化。即便没有外部攻击者,输入' OR '1'='1也会查出全部数据并拉慢界面。用 PreparedStatement 绑定参数,代码量没有增加多少,安全性高一个等级。
// 动态 SQL:按关键字和分类筛选 public List<Product> query(String keyword, Integer categoryId) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT p.*, s.quantity FROM product p LEFT JOIN stock s ON p.product_id=s.product_id WHERE p.status=1"); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.isBlank()) { sql.append(" AND (p.product_name LIKE ? OR p.barcode LIKE ? OR p.py_code LIKE ?)"); String like = "%" + keyword + "%"; params.add(like); params.add(like); params.add(like); } if (categoryId != null) { sql.append(" AND p.category_id = ?"); params.add(categoryId); } // 用 pstmt.setObject(idx, params.get(idx-1)) 循环赋值 // 最后返回 List<Product> }用LIKE ?而不是LIKE '%' || ? || '%',是为了把通配符留给参数值去处理,数据库预编译也能缓存执行计划。params列表的顺序必须和 SQL 中?出现顺序一致;如果加了字段就漏加参数,运行时会报索引越界。这个方法在 Java 面试中容易出现“动态 sql 拼接和 PreparedStatement 哪个更安全”的题目,能把上面两点讲清楚,比背概念更落地。
6. 给进销存源码做对账和界面提速的两个小改造
6.1 用一条 SQL 验证流水累计量等于当前库存
在跑完一轮业务后,最怕界面显示有库存,数据库流水表却缺记录。可以用stock_flow表的direction字段做汇总,和stock表直接对比。
-- 全量核对:流水累计量与库存结存必须一致 SELECT f.product_id, p.product_name, SUM(f.direction * f.quantity) AS calc_qty, s.quantity AS stock_qty FROM stock_flow f JOIN product p ON p.product_id = f.product_id JOIN stock s ON s.product_id = f.product_id GROUP BY f.product_id, p.product_name, s.quantity HAVING calc_qty <> stock_qty;这条 SQL 的前提是stock_flow里每一笔业务都写了流水,并且direction只有 1 和 -1 两种值。查询结果里有任何一行,都说明某张采购单或销售单在保存时没有走完整事务,按ref_no去查对应单据就能定位。如果在你的表结构里没有direction,把direction * quantity改成CASE WHEN ref_type=1 THEN quantity ELSE -quantity END,效果一样,只是稍微慢一点。
6.2 用 SwingWorker 把数据库查询移出事件线程
点击查询按钮后,如果界面卡住无法拖动,通常是数据查询在 UI 线程里执行。将耗时 JDBC 操作放到SwingWorker.doInBackground,再把结果切回 EDT 更新表格。
JButton btn = new JButton("查询"); btn.addActionListener(e -> { btn.setEnabled(false); new SwingWorker<List<Product>, Void>() { @Override protected List<Product> doInBackground() throws Exception { return productDao.query(keywordField.getText()); } @Override protected void done() { try { model.setProducts(get()); } catch (Exception ex) { JOptionPane.showMessageDialog(btn, ex.getMessage()); } finally { btn.setEnabled(true); } } }.execute(); });doInBackground里不能调用任何 Swing 组件,否则还是会造成 UI 状态不安全;done方法会自动运行在 EDT 上,所以在这里更新表格模型是安全的。按钮设置setEnabled(false)是为了防止用户在查询期间重复点击,发起多个并行查询。把这段代码替换原有的同步查询块,重新编译后,查询上万行商品时窗口依然能正常拖动,做演示和交接都更专业。
本文还有配套的精品资源,点击获取