简介这是一套面向高校计算机专业学生与Java Web初学者的进销存管理系统毕业设计资料基于JSP表现层与MSSQL数据库实现采用表现层、业务逻辑层、数据访问层的三层架构通过JDBC完成数据交互。系统覆盖商品管理、供应商管理、进货管理、销售管理、库存预警与报表分析等核心模块可作为课程设计、毕设选题或Java Web入门练手项目。压缩包共163个文件约1.7MB包含31个java源文件、105个class编译文件、4个jar依赖包以及png界面截图、mdf与ldf数据库文件、xls数据表、doc毕设文档和txt说明等源码与文档配套齐全。目前已有249人学习下载。读者可从中获取完整可运行的工程代码、数据库文件与毕业设计文档通过阅读JSP页面、Servlet与JavaBean的调用关系理解前端请求如何与后端逻辑及数据库关联并参考需求分析、系统设计、测试评价等章节梳理开发流程适合需要完整项目范例与排错思路的学习者。1. 从一张出库单说起为什么 JSPMSSQL 的进销存至今仍是毕设硬通货很多同学第一次接触“进销存”这三个字脑子里浮现的是 ERP 那种庞然大物觉得离自己很远。但把场景缩小到一间五金店、一个校园文具仓、一家小型贸易公司事情就变得具体了采购进来一批货库存要加销售出去一批货库存要减月底要盘一次账知道哪些货压着钱、哪些货快断供。这套“进货、销售、存货”的闭环就是进销存管理系统要解决的全部问题。标题里的 JSPMSSQL指的是用 JavaServer Pages 做前端页面渲染、用 Microsoft SQL Server 做数据持久化这是国内高校毕设里非常经典的一套组合资料多、环境成熟、答辩时老师也认。它适合谁适合正在做课程设计或毕业设计、需要一套能跑起来、能讲清楚、能自己改的完整系统的同学也适合刚入行、想用一个真实业务把 Java Web 基础串一遍的开发者。这篇文章不空谈架构而是按“建库、连库、写业务、避坑、验收”的顺序把一套可复现的进销存系统拆开讲。你照着做能拿到一个能登录、能管商品、能开单、能查库存的可用版本而不是一个只能截图交差的空壳。2. 先把数据模型定死商品、单据、库存三张核心表怎么设计进销存系统翻车十有八九不是代码写错而是表结构一开始就没想清楚。我见过太多同学上来就建一张“库存表”字段里塞商品名、数量、单价结果采购和销售一并发数据就对不上。正确的做法是把“静态的商品信息”和“动态的库存变动”分开用单据驱动库存。2.1 商品表、单据主表、单据明细表的分工商品表只存不变或很少变的东西商品编号、名称、规格、单位、参考进价、参考售价、当前库存。注意“当前库存”这个字段是冗余的它的值应该由单据累加得出但为了查询快我们保留它并在每次单据过账时同步更新。单据主表存一张单子的头信息单号、类型采购入库还是销售出库、往来单位、制单人、制单时间、总金额、状态。单据明细表存这张单子里每一行商品属于哪张主单、哪个商品、数量、单价、金额。这样设计的好处是库存的每一次变化都能追溯到一张具体的单据月底对账时不会出现“库存对不上但不知道哪笔错了”的黑匣子。下面给出建表语句字段类型按 MSSQL 的习惯来主键用自增整数单号用字符串并加唯一索引。-- 商品表静态信息 冗余当前库存 CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductCode VARCHAR(32) NOT NULL UNIQUE, -- 商品编号业务唯一 ProductName NVARCHAR(64) NOT NULL, Spec NVARCHAR(64) NULL, -- 规格可空 Unit NVARCHAR(16) NULL, -- 单位 PurchasePrice DECIMAL(18,2) DEFAULT 0, -- 参考进价 SalePrice DECIMAL(18,2) DEFAULT 0, -- 参考售价 StockQty INT DEFAULT 0 -- 当前库存由单据维护 ); -- 单据主表一张单子的头 CREATE TABLE BillMain ( BillId INT IDENTITY(1,1) PRIMARY KEY, BillNo VARCHAR(32) NOT NULL UNIQUE, -- 单号如 CG20250101001 BillType TINYINT NOT NULL, -- 1采购入库 2销售出库 Partner NVARCHAR(64) NULL, -- 供应商或客户 CreateBy NVARCHAR(32) NULL, CreateTime DATETIME DEFAULT GETDATE(), TotalAmount DECIMAL(18,2) DEFAULT 0, Status TINYINT DEFAULT 1 -- 1已过账 0已作废 ); -- 单据明细表单子里的每一行 CREATE TABLE BillDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, BillId INT NOT NULL, -- 关联主表 ProductId INT NOT NULL, -- 关联商品 Qty INT NOT NULL, Price DECIMAL(18,2) NOT NULL, Amount AS (Qty * Price) PERSISTED -- 计算列自动算金额 );逻辑说明Amount用计算列而不是每次插入时手算能避免应用层算错或漏算。StockQty放在商品表里是典型的反范式设计用空间换查询速度代价是每次过账都要在同一个事务里更新它。参数上DECIMAL(18,2)足够应付绝大多数中小场景的金额别用FLOAT否则对账时会出现 0.0000001 的玄学误差。2.2 库存变动为什么必须和单据过账绑在一个事务里假设销售出库一张单里面有 3 行商品。应用层要做三件事写主表、写三条明细、把三个商品的库存各减掉对应数量。这三件事必须全部成功或全部失败。如果主表写进去了、库存没减系统就丢了一笔出库如果库存减了、明细没写月底就查不出这笔货去哪了。MSSQL 里用显式事务包住配合TRY...CATCH回滚。BEGIN TRY BEGIN TRANSACTION; -- 1. 插入主表用 SCOPE_IDENTITY 拿回自增 BillId INSERT INTO BillMain (BillNo, BillType, Partner, CreateBy, TotalAmount) VALUES (XS20250101001, 2, N某客户, Nadmin, 0); DECLARE billId INT SCOPE_IDENTITY(); -- 2. 插入明细这里只演示一行实际由应用层循环 INSERT INTO BillDetail (BillId, ProductId, Qty, Price) VALUES (billId, 1, 5, 20.00); -- 3. 扣减库存并检查是否扣成负数 UPDATE Product SET StockQty StockQty - 5 WHERE ProductId 1; IF EXISTS (SELECT 1 FROM Product WHERE ProductId 1 AND StockQty 0) THROW 50001, N库存不足出库失败, 1; -- 4. 回填主表总金额 UPDATE BillMain SET TotalAmount (SELECT SUM(Amount) FROM BillDetail WHERE BillId billId) WHERE BillId billId; COMMIT TRANSACTION; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRANSACTION; -- 把错误继续抛给应用层由页面提示 THROW; END CATCH;这段 SQL 的关键点有三个。第一SCOPE_IDENTITY()取当前作用域刚插入的自增值比IDENTITY安全后者在有触发器时会取错。第二库存扣减后立刻检查负数用THROW主动报错触发回滚这是防止超卖的最后一道闸。第三总金额在明细写完后回填保证主表金额和明细永远一致。参数上THROW的错误号要大于 50000这是 MSSQL 留给用户自定义错误的区间。3. 用 JSP 把后端串起来连接池、DAO 与页面渲染的最小闭环表建好了接下来要让 Java 代码能读写它。JSP 本身是页面技术但真正干活的应该是它背后的 Servlet 和 JavaBean。很多毕设代码把 SQL 直接写在 JSP 的% %里这是最典型的翻车写法页面和数据库耦合改一个字段要翻十几个文件而且连接不关会导致连接池耗尽。正确的分层是 JSP 只负责显示Servlet 接请求DAO 管数据库。3.1 配置 MSSQL 连接别再用 DriverManager 裸连裸连的意思是每次请求都DriverManager.getConnection()用完close()。在并发稍微高一点的场景频繁建立 TCP 连接的开销会让系统慢得离谱。常见做法是用连接池Tomcat 自带 DBCP在META-INF/context.xml里配好JSP/Servlet 通过 JNDI 拿。!-- META-INF/context.xml -- Context Resource namejdbc/inventoryDB authContainer typejavax.sql.DataSource driverClassNamecom.microsoft.sqlserver.jdbc.SQLServerDriver urljdbc:sqlserver://localhost:1433;databaseNameInventoryDB;encryptfalse usernamesa passwordYourStrongPassword maxTotal20 maxIdle10 maxWaitMillis10000/ /Context逻辑说明maxTotal是池里最多 20 个连接maxWaitMillis是拿不到连接时最多等 10 秒超时就抛异常避免请求无限挂起。encryptfalse是本地开发图省事生产环境要开加密并配证书。参数上url里的databaseName必须和你建库时的名字一致端口默认 1433如果装 MSSQL 时改过要同步改。在 Java 代码里这样取连接// DBUtil.java统一从 JNDI 拿连接 public class DBUtil { private static DataSource ds; static { try { Context ctx new InitialContext(); ds (DataSource) ctx.lookup(java:comp/env/jdbc/inventoryDB); } catch (NamingException e) { throw new RuntimeException(连接池初始化失败, e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }注意lookup的字符串是java:comp/env/加上context.xml里的name少一段就报NameNotFoundException这是新手最常见的翻车点之一。3.2 商品列表页从 Servlet 到 JSP 的完整数据流以商品列表为例走一遍完整链路。Servlet 收到/product/list请求调 DAO 查全部商品把结果放进request转发给 JSPJSP 用 JSTL 遍历渲染成表格。// ProductServlet.java 片段 protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { ListProduct list new ArrayList(); String sql SELECT ProductId, ProductCode, ProductName, Spec, Unit, PurchasePrice, SalePrice, StockQty FROM Product ORDER BY ProductId; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Product p new Product(); p.setProductId(rs.getInt(ProductId)); p.setProductCode(rs.getString(ProductCode)); p.setProductName(rs.getString(ProductName)); p.setStockQty(rs.getInt(StockQty)); // 其余字段省略 list.add(p); } } catch (SQLException e) { throw new ServletException(查询商品失败, e); } req.setAttribute(products, list); req.getRequestDispatcher(/product/list.jsp).forward(req, resp); }逻辑说明用try-with-resources保证Connection、PreparedStatement、ResultSet按顺序自动关闭这是 JDBC 里最省心的写法。PreparedStatement除了防 SQL 注入还能让 MSSQL 复用执行计划。参数上查询全部商品没有占位符如果加条件比如按名称模糊查要用?占位再setString绝不能拼字符串。对应的 JSP 页面% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table border1 trth编号/thth名称/thth规格/thth库存/th/tr c:forEach items${products} varp tr td${p.productCode}/td td${p.productName}/td td${p.spec}/td td${p.stockQty}/td /tr /c:forEach /tablec:forEach是 JSTL 的核心标签比在 JSP 里写 Java 循环干净得多。注意页面顶部contentType要设成UTF-8否则中文商品名会变问号这是编码问题的第一道关。4. 采购入库与销售出库把单据过账逻辑写对单据过账是整个系统的心脏。采购入库让库存增加销售出库让库存减少两者结构对称但校验规则不同入库一般不校验库存上限出库必须校验库存下限。把这两条路径写清楚系统就活了。4.1 入库单的保存与库存增加入库的业务动作是用户在前端填一张单选供应商、加若干行商品和数量提交后系统生成单号、写主表、写明细、加库存。单号生成规则常见做法是“前缀 日期 三位流水”比如CG20250101001。流水号用当天已有单数加一注意并发下要用事务或数据库序列简单场景可以在插入前SELECT COUNT(*)再拼但严格来说有并发风险毕设场景够用。// 入库核心逻辑简化版省略异常包装 public void purchaseIn(String partner, ListBillItem items, String operator) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交手动控制事务 String billNo genBillNo(conn, CG); // 插入主表 String sqlMain INSERT INTO BillMain (BillNo, BillType, Partner, CreateBy, TotalAmount) VALUES (?, 1, ?, ?, 0); PreparedStatement psMain conn.prepareStatement(sqlMain, Statement.RETURN_GENERATED_KEYS); psMain.setString(1, billNo); psMain.setString(2, partner); psMain.setString(3, operator); psMain.executeUpdate(); ResultSet keys psMain.getGeneratedKeys(); keys.next(); int billId keys.getInt(1); // 循环插入明细并加库存 String sqlDetail INSERT INTO BillDetail (BillId, ProductId, Qty, Price) VALUES (?,?,?,?); String sqlStock UPDATE Product SET StockQty StockQty ? WHERE ProductId ?; PreparedStatement psDetail conn.prepareStatement(sqlDetail); PreparedStatement psStock conn.prepareStatement(sqlStock); for (BillItem it : items) { psDetail.setInt(1, billId); psDetail.setInt(2, it.getProductId()); psDetail.setInt(3, it.getQty()); psDetail.setBigDecimal(4, it.getPrice()); psDetail.addBatch(); psStock.setInt(1, it.getQty()); psStock.setInt(2, it.getProductId()); psStock.addBatch(); } psDetail.executeBatch(); psStock.executeBatch(); // 回填总金额 String sqlTotal UPDATE BillMain SET TotalAmount (SELECT ISNULL(SUM(Amount),0) FROM BillDetail WHERE BillId ?) WHERE BillId ?; PreparedStatement psTotal conn.prepareStatement(sqlTotal); psTotal.setInt(1, billId); psTotal.setInt(2, billId); psTotal.executeUpdate(); conn.commit(); } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }逻辑说明setAutoCommit(false)是手动事务的开关所有写操作完成后commit()任何一步抛异常就rollback()。addBatch()和executeBatch()把多条插入合并成一次网络往返明细行多时性能提升明显。RETURN_GENERATED_KEYS让 JDBC 返回刚插入的自增主键比再查一次MAX(BillId)可靠。参数上ISNULL(SUM(Amount),0)处理明细为空的情况避免总金额变成 NULL。4.2 出库单的库存校验与超卖拦截出库和入库代码结构几乎一样区别在库存更新是减且减之前要确认够。最稳的做法是在UPDATE的WHERE里带上库存条件用受影响行数判断是否成功而不是先查再减——先查再减在并发下会超卖。-- 出库时扣库存只有库存足够才更新成功 UPDATE Product SET StockQty StockQty - ? WHERE ProductId ? AND StockQty ?;// 在出库循环里检查受影响行数 psStock.setInt(1, it.getQty()); psStock.setInt(2, it.getProductId()); psStock.setInt(3, it.getQty()); int affected psStock.executeUpdate(); if (affected 0) { throw new SQLException(商品[ it.getProductId() ]库存不足出库失败); }逻辑说明WHERE StockQty ?把校验和扣减合成一条原子语句数据库层面保证不会扣成负数。affected 0说明库存不够抛异常触发整个事务回滚前面已扣的商品也会恢复。参数上三个?分别是扣减数量、商品 ID、校验阈值后两个值相同写的时候别搞混顺序。5. 避坑与排查JSPMSSQL 进销存最常见的五个翻车现场这一章是我自己踩过、也帮别人修过的真实问题按“现象 → 原因 → 解决”写。你如果卡住了大概率能在这里找到对应。5.1 中文商品名存进去变问号现象页面上输入“螺丝刀”保存后数据库里是??或乱码。原因MSSQL 的VARCHAR按数据库排序规则存字符如果排序规则不是中文非 Unicode 字符会丢。解决存中文的字段一律用NVARCHARJDBC 连接串加sendStringParametersAsUnicodetrue新版驱动默认就是 trueJSP 页面contentType和pageEncoding都设UTF-8。三处缺一不可只改一处照样乱码。5.2 连接池报 “Connection is closed” 或连接耗尽现象系统跑一会儿就卡死日志里一堆Connection is closed或等待超时。原因DAO 里拿了连接没关或者ResultSet、Statement没关池里 20 个连接很快被占满。解决全部改成try-with-resources让编译器帮你关。另外检查有没有在 JSP 里直接DriverManager.getConnection那种写法不受池管理必须清掉。5.3 单据保存一半库存对不上现象主表有记录明细少一行或者库存扣了但单子没生成。原因没有用事务或者用了事务但异常被catch后没rollback就继续执行。解决把整张单的写操作包在一个Connection里setAutoCommit(false)catch里rollbackfinally里恢复autoCommit并关连接。注意别在循环里反复开关事务那样等于没事务。5.4 单号重复导致插入失败现象两个人同时开单生成同一个单号第二个插入报唯一约束冲突。原因单号用SELECT COUNT(*)1生成并发下两个请求读到同样的计数。解决简单场景给单号生成加synchronized或数据库行锁更稳的做法是建一张序列表用UPDATE ... SET seq seq 1原子自增再读回。毕设场景用synchronized块包住生成逻辑即可但要知道它只在本机单实例有效。5.5 库存显示和实际不符现象商品列表的库存数和单据明细累加出来的数对不上。原因有人直接改过Product.StockQty或者某次过账事务回滚了但库存更新没回滚说明库存更新不在事务里。解决库存字段只允许通过单据过账修改禁止在商品编辑页直接改。写一个对账 SQL定期跑一遍把差异揪出来。-- 对账商品表库存 vs 单据累加库存 SELECT p.ProductId, p.ProductName, p.StockQty AS 表内库存, ISNULL(SUM(CASE WHEN b.BillType 1 THEN d.Qty WHEN b.BillType 2 THEN -d.Qty END), 0) AS 单据库存 FROM Product p LEFT JOIN BillDetail d ON p.ProductId d.ProductId LEFT JOIN BillMain b ON d.BillId b.BillId AND b.Status 1 GROUP BY p.ProductId, p.ProductName, p.StockQty HAVING p.StockQty ISNULL(SUM(CASE WHEN b.BillType 1 THEN d.Qty WHEN b.BillType 2 THEN -d.Qty END), 0);这条 SQL 返回的就是所有对不上的商品HAVING里比较两个值。跑出来有记录就说明有绕过单据改库存的操作或者有事务没包全。6. 验收与进阶让这套系统经得起答辩和真实使用系统能跑通只是及格线要让它经得起答辩老师的追问、也经得起自己后续改需求还得做几件事。这一章讲验收清单和一个具体进阶技巧都是我做完项目后回头看最值得投入的地方。6.1 一份能自检的验收清单别等到答辩前一天才发现某个功能是坏的。下面这张表按模块列了最小验收项你逐条点一遍全过才算完成。模块验收项通过标准登录正确账号密码进入主页会话保持登录错误密码提示错误不进入系统商品新增商品列表出现编号唯一商品编辑商品修改生效库存不被误改采购开入库单主表、明细、库存三者一致销售开出库单库存足够时成功不足时整单回滚销售超卖测试库存 5 卖 6报错且库存不变查询按单号查单据能查到主表和明细对账跑对账 SQL无差异记录这张表的价值在于它把“感觉做完了”变成“逐项验证过”。尤其是超卖测试和对账 SQL很多同学的系统在这两项上会暴露问题。6.2 用数据库触发器做库存兜底应用层的事务已经能保证库存正确但万一有人直接连数据库改单据库存就失控了。一个进阶做法是在BillDetail上建触发器插入明细时自动更新库存这样即使应用层漏写库存更新数据库也能兜住。代价是逻辑分散在应用和数据库两处调试时容易懵所以只建议在明确需要兜底的场景用。CREATE TRIGGER trg_BillDetail_Stock ON BillDetail AFTER INSERT AS BEGIN -- 入库加库存 UPDATE p SET p.StockQty p.StockQty i.Qty FROM Product p INNER JOIN inserted i ON p.ProductId i.ProductId INNER JOIN BillMain b ON i.BillId b.BillId WHERE b.BillType 1; -- 出库减库存 UPDATE p SET p.StockQty p.StockQty - i.Qty FROM Product p INNER JOIN inserted i ON p.ProductId i.ProductId INNER JOIN BillMain b ON i.BillId b.BillId WHERE b.BillType 2; END;逻辑说明inserted是触发器里的虚拟表存本次插入的所有明细行。按BillType分流入库加、出库减。注意触发器在同一个事务里执行如果应用层也更新了库存就会重复加所以用了触发器就要把应用层的库存更新去掉二选一别两套并存。参数上触发器对批量插入也生效inserted里有多少行就处理多少行。6.3 我自己的习惯做完这套系统后我养成了一个习惯任何涉及金额和库存的写操作先在纸上画出“哪几张表要动、动的前后顺序、哪一步可能失败”画清楚了再写代码。进销存的坑几乎都出在顺序和事务边界上而不是语法。另一个习惯是每加一个功能先想好它的对账 SQL 怎么写——如果写不出对账 SQL说明这个功能的数据流向还没想透。这两个习惯帮我省下了大量返工时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?