
简介一份基于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_priceProduct往来单位partner_id, partner_name, typePartner仓库warehouse_id, warehouse_name, addressWarehouse结算账户account_id, account_name, balanceAccount这四个角色在界面上分属“基础数据”菜单但每张业务单据都离不开它们。判断一套源码设计得是否清晰就看业务单据表是直接存字段冗余还是只存 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 jarsql 里是建库脚本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?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/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 里的useSSLfalse是本地开发标准配置避免 MySQL 8 的 SSL 证书警告serverTimezoneAsia/Shanghai负责解决 CST 时区歧义characterEncodingutf8配合建库时的 utf8mb4中文显示不乱码。特别提醒如果你用 MySQL 8驱动类必须是com.mysql.cj.jdbc.DriverMySQL 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 rootlocalhostDBUtil 账号密码不对先在命令行登录 MySQL确认 root 的密码和允许的 hostUnknown database jxc建库不成功或库名不一致重新执行建库和导入再用SHOW TABLES验证Could not find or load main class主类全限定名写错看编译输出的包名改成真实路径按钮点击后中文问号编译时缺-encoding UTF-8加编码参数并把窗体字体设置为宋体这一章解决了“跑起来”但运行只是第一步。真正难的是后续改需求比如加一个采购员字段很多人不知道该动 table、DAO 还是 Swing 界面下一章就从设计文档的顺序入手。4. 相关设计文档的正确打开方式先读模块边界再读 SQL4.1 一张模块划分图框定源码包里的类老项目的设计文档比 Spring Boot 的接口文档更有参考价值因为它通常从模块图开始。最常见的进销存模块划分是系统管理、基础数据、采购管理、销售管理、库存管理、报表查询。六个模块直接对应源码里的六个包名。模块典型相关类对应数据表系统管理UserDao, RoleDaosys_user, sys_role基础数据ProductDao, PartnerDaoproduct, partner采购管理PurchaseMainFrame, PurchaseDetailpurchase_main, purchase_detail销售管理SaleMainFrame, SaleDetailsale_main, sale_detail库存管理StockDao, StockFlowDaostock, stock_flow报表查询ReportFrame, SaleReportDaosale_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 保存登录信息而不是到处传 UserSwing 没有 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这在数据量小的时候看不出问题一旦需要排序、过滤、修改单元格就会把表格和业务逻辑混在一起。更稳的做法是自定义表格模型。// 表格模型从 ListProduct 取数据与 ResultSet 解耦 public class ProductTableModel extends AbstractTableModel { private final String[] columns {商品ID, 商品名称, 规格, 库存, 售价}; private final ListProduct products new ArrayList(); public void setProducts(ListProduct 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, ListSaleDetail 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 1sale_main无主单明细无法归属step 2sale_detail商品明细缺失报表数量偏大step 3stock_flow, stock库存流水断链对账不平这张表也解释了为什么销售单不能只写在界面上单头、明细、库存流水必须被同一个 commit 保住缺一步整个系统的可信度就崩了。5.4 动态 SQL 的参数绑定比字符串拼接更值得养成习惯进销存查询界面的筛选条件通常是商品名称模糊、条形码、分类、库存区间。老代码里为了省事直接用字符串拼接关键字这在单机版看着没风险但一个单引号就能让 SQL 结构变化。即便没有外部攻击者输入 OR 11也会查出全部数据并拉慢界面。用 PreparedStatement 绑定参数代码量没有增加多少安全性高一个等级。// 动态 SQL按关键字和分类筛选 public ListProduct 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_ids.product_id WHERE p.status1); ListObject 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)) 循环赋值 // 最后返回 ListProduct }用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_type1 THEN quantity ELSE -quantity END效果一样只是稍微慢一点。6.2 用 SwingWorker 把数据库查询移出事件线程点击查询按钮后如果界面卡住无法拖动通常是数据查询在 UI 线程里执行。将耗时 JDBC 操作放到SwingWorker.doInBackground再把结果切回 EDT 更新表格。JButton btn new JButton(查询); btn.addActionListener(e - { btn.setEnabled(false); new SwingWorkerListProduct, Void() { Override protected ListProduct 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)是为了防止用户在查询期间重复点击发起多个并行查询。把这段代码替换原有的同步查询块重新编译后查询上万行商品时窗口依然能正常拖动做演示和交接都更专业。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。