简介:这是一套面向高校计算机专业学生与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. 从一张出库单说起:为什么 JSP+MSSQL 的进销存至今仍是毕设硬通货
很多同学第一次接触“进销存”这三个字,脑子里浮现的是 ERP 那种庞然大物,觉得离自己很远。但把场景缩小到一间五金店、一个校园文具仓、一家小型贸易公司,事情就变得具体了:采购进来一批货,库存要加;销售出去一批货,库存要减;月底要盘一次账,知道哪些货压着钱、哪些货快断供。这套“进货、销售、存货”的闭环,就是进销存管理系统要解决的全部问题。标题里的 JSP+MSSQL,指的是用 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'某客户', N'admin', 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 name="jdbc/inventoryDB" auth="Container" type="javax.sql.DataSource" driverClassName="com.microsoft.sqlserver.jdbc.SQLServerDriver" url="jdbc:sqlserver://localhost:1433;databaseName=InventoryDB;encrypt=false" username="sa" password="YourStrongPassword" maxTotal="20" maxIdle="10" maxWaitMillis="10000"/> </Context>逻辑说明:maxTotal是池里最多 20 个连接,maxWaitMillis是拿不到连接时最多等 10 秒,超时就抛异常,避免请求无限挂起。encrypt=false是本地开发图省事,生产环境要开加密并配证书。参数上,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,转发给 JSP,JSP 用 JSTL 遍历渲染成表格。
// ProductServlet.java 片段 protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { List<Product> 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 contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1"> <tr><th>编号</th><th>名称</th><th>规格</th><th>库存</th></tr> <c:forEach items="${products}" var="p"> <tr> <td>${p.productCode}</td> <td>${p.productName}</td> <td>${p.spec}</td> <td>${p.stockQty}</td> </tr> </c:forEach> </table><c:forEach>是 JSTL 的核心标签,比在 JSP 里写 Java 循环干净得多。注意页面顶部contentType要设成UTF-8,否则中文商品名会变问号,这是编码问题的第一道关。
4. 采购入库与销售出库:把单据过账逻辑写对
单据过账是整个系统的心脏。采购入库让库存增加,销售出库让库存减少,两者结构对称,但校验规则不同:入库一般不校验库存上限,出库必须校验库存下限。把这两条路径写清楚,系统就活了。
4.1 入库单的保存与库存增加
入库的业务动作是:用户在前端填一张单,选供应商、加若干行商品和数量,提交后系统生成单号、写主表、写明细、加库存。单号生成规则常见做法是“前缀 + 日期 + 三位流水”,比如CG20250101001。流水号用当天已有单数加一,注意并发下要用事务或数据库序列,简单场景可以在插入前SELECT COUNT(*)再拼,但严格来说有并发风险,毕设场景够用。
// 入库核心逻辑(简化版,省略异常包装) public void purchaseIn(String partner, List<BillItem> 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. 避坑与排查:JSP+MSSQL 进销存最常见的五个翻车现场
这一章是我自己踩过、也帮别人修过的真实问题,按“现象 → 原因 → 解决”写。你如果卡住了,大概率能在这里找到对应。
5.1 中文商品名存进去变问号
现象:页面上输入“螺丝刀”,保存后数据库里是??或乱码。原因:MSSQL 的VARCHAR按数据库排序规则存字符,如果排序规则不是中文,非 Unicode 字符会丢。解决:存中文的字段一律用NVARCHAR,JDBC 连接串加sendStringParametersAsUnicode=true(新版驱动默认就是 true),JSP 页面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里rollback,finally里恢复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,说明这个功能的数据流向还没想透。这两个习惯帮我省下了大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取