简介基于Java的仓库管理系统是一套采用SSMSpringSpringMVCMyBatis框架开发的完整毕设项目面向计算机相关专业毕业生和需要项目实战的Java学习者可直接用于毕业设计或课程设计也可作为SSM整合练习的参考范例。整个资源包共3个文件包含项目源码zip包、MySQL数据库脚本sql文件以及项目说明txt文档整体仅19.56MB轻量易部署。系统功能覆盖用户登录、物资添加与查询、入库物资管理、出库物资管理、库存物资管理等核心仓储业务模块代码分层清晰数据库设计完整便于理解业务逻辑与二次开发。资源还附带了软件工具与运行环境说明能够帮助读者快速在JDK、Eclipse、Tomcat环境下跑通项目并查看实际效果。目前已有3171人学习下载适合需要快速完成毕设或希望提升Java Web开发能力的同学参考学习。1. 为什么仓库管理系统是毕设里的“性价比之王”每年毕设选题阶段最纠结的就是找一套既不算太难、又能让答辩老师觉得“工作量够”的题目。“基于Java的仓库管理系统”恰好站在这个交叉点上它属于典型的管理信息系统CRUD为主、逻辑清楚但又不只是简单的注册登录里面还有库存、单据、权限、报表这些可以深挖的点复杂度刚好卡在“努力一下能做出来”的区间。这套项目源码加数据库脚本说白了就是给你一个已经跑通的底子你要做的是把它弄懂、改出自己的东西再顺利通过答辩。适合什么人Java基础刚刚学完、想用Spring Boot练手的同学或者论文题目写的是“基于B/S的物资管理系统”但还没想好怎么落地的同学。它能帮你解决的不只是“代码怎么写”还有“数据库从哪来”“环境怎么配”“演示时怎么讲才不像背稿”这些更要命的问题。下面的内容会按技术选型、数据库脚本、核心模块、部署避坑、进阶优化一条线讲清楚照着走就能把项目跑起来。2. 技术选型与源码结构Spring Boot MyBatis 的常见骨架仓库管理系统在毕设里出现频率极高网上能找到的源码版本五花八门。有的用 JSP Servlet有的用 SSH有的用 SSM这两年更多是 Spring Boot Vue 前后端分离。拿到源码第一步不是打开 IDEA 就开始运行而是先看技术栈判断这套代码你能不能 hold 住。2.1 为什么“老毕设”偏爱 JSP而新项目更适合 Spring Boot如果你下载到的源码是 JSP Servlet 的版本别急着嫌弃。这类项目年代久远但结构极其直白一个 Servlet 对应一个页面doGet和doPost里写业务逻辑数据库连接用 JDBC 或 DbUtils。它的优点是你打开代码从request.getParameter到response.sendRedirect一眼就能看懂整个请求生命周期非常适合答辩时“被问到哪都能答上来”。但问题也明显JSP 里穿插 Java 代码页面逻辑和业务逻辑混在一起改一个按钮就要翻几十行。如果要在这个基础上加库存预警、导出报表代码会越来越难维护。现在更主流的做法是 Spring Boot MyBatis Thymeleaf或者 Spring Boot Vue 前后端分离。对毕设来说我一般推荐前者。原因有三个Spring Boot 内置 Tomcat打包成 jar 就能跑不用单独装 Tomcat 再部署 war环境问题少一大半MyBatis 的 SQL 写在 XML 里和 Java 代码分离答辩时被问到“这段 SQL 怎么优化”能单独拎出来讲Thymeleaf 服务端渲染不需要额外起前端工程项目结构简单查重和代码量也都够。如果你已经会的技术栈是 Spring Boot Vue那也不要换。前后端分离的仓库系统做出来更现代但要注意 Vue 打包后的 dist 文件怎么集成到 Spring Boot 里跨域问题怎么解决这些都是答辩高频问题。2.2 一套标准仓库系统的目录结构和每个包的作用不管源码长什么样Java 仓库管理系统基本都逃不开下面的目录结构。先把这个看懂后面改代码才有方向src/main/java/com/example/warehouse/ ├── controller/ // 接收请求返回页面或JSON │ ├── LoginController.java │ ├── ProductController.java │ └── StockController.java ├── service/ // 业务逻辑层处理事务 │ ├── ProductService.java │ ├── InboundService.java │ └── OutboundService.java ├── mapper/ // MyBatis 的 Mapper 接口 │ ├── ProductMapper.java │ └── StockMapper.java ├── entity/ // 实体类对应数据库表 │ ├── Product.java │ ├── User.java │ └── Stock.java ├── interceptor/ // 登录拦截器、权限拦截器 │ └── LoginInterceptor.java src/main/resources/ ├── mapper/ // MyBatis 的 XML 文件 │ ├── ProductMapper.xml │ └── StockMapper.xml ├── templates/ // Thymeleaf 模板页面 │ ├── login.html │ ├── product/list.html │ └── stock/list.html └── application.yml // 配置文件端口、数据库连接 sql/ └── warehouse.sql // 建库建表 初始化数据每个包干的事要从一条请求说起用户点“商品列表”ProductController收到请求调用ProductService.list()ProductService调用ProductMapper.selectList()MyBatis 根据ProductMapper.xml里的 SQL 查出数据封装成Product对象列表Controller 把列表塞进 Model 返回给product/list.html渲染。这个过程要记牢因为答辩老师百分之百会问“你从前端页面到数据库这条数据流是怎么走的”你能顺着 controller → service → mapper → DB 从头说一遍这道题就算过了。2.3 参数配置application.yml 里的 3 个必调项拿到源码后打开application.yml配置文件。我见过太多同学因为没改这里的配置项目启动直接报红。最基本的三个配置项必须检查server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/warehouse?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你自己的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserver.port如果和本地已经跑的服务冲突改成 8081 或 9090 都行但记得改完启动日志看到端口变了再访问页面。datasource.url里的characterEncodingutf8mb4很关键不给它后面中文乱码折磨死你。serverTimezoneAsia/Shanghai是解决 MySQL 8.x 时区报错用的。map-underscore-to-camel-case这一项打开后数据库字段product_name能自动映射到实体类属性productName不用在 XML 里一个一个写字段映射关系。有些源码没开这个你就要去 XML 里补resultMap这是个容易漏但必考的细节。3. 数据库脚本导入建库、建表、初始化数据一次性跑通仓库管理系统最怕的不是代码写不出来而是数据库建不对。一套完整的毕设源码通常会附带数据库脚本文件名字可能叫warehouse.sql、db_warehouse.sql或者直接放在sql目录下。所谓“数据库脚本”本质就是一段能自动完成建库、建表、插入初始化数据的 SQL 文本。你不需要手动点鼠标去设计表、加字段执行脚本就全都搞定。3.1 一个仓库管理系统至少需要哪几张表先看表结构确认这套脚本是否完整。我经手过的仓库管理系统源码最基础的至少有 6 张表sys_user登录用户表字段含username、password、rolerole 区分管理员和操作员product商品信息表含商品编号、名称、规格、单位、分类supplier供应商表记录供货商名称、联系人、电话inbound_bill入库单表每一条记录一次入库含商品ID、数量、单价、操作人、入库时间outbound_bill出库单表结构类似入库单但增加收货方字段stock库存表对应每个商品的当前库存数量以及预警阈值。另外强化版的会有customer客户表、category商品分类表、sys_log操作日志表。拿到脚本后先数一下有几张表再对照题目要求看缺不缺。如果论文里写了“库存预警”功能但脚本里没有warn_quantity字段那就是功能缺失需要自己补。3.2 数据库脚本的导入步骤命令行和 IDEA 两种方式打开warehouse.sql你会发现开头通常是 DROP DATABASE 再 CREATE DATABASE接着用USE warehouse;切换库然后按顺序建表最后是一堆 INSERT 语句。标准脚本长这样DROP DATABASE IF EXISTS warehouse; CREATE DATABASE warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE warehouse; DROP TABLE IF EXISTS sys_user; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role VARCHAR(16) DEFAULT OPERATOR, real_name VARCHAR(32) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始化管理员账号 INSERT INTO sys_user (username, password, role, real_name) VALUES (admin, 123456, ADMIN, 系统管理员);注意DROP TABLE IF EXISTS这行。它的意思是如果表已存在就先删掉再建。好处是脚本可以重复执行而不报错坏处是一旦执行你之前往里加的数据全部清空。第一次导入没问题如果后续想把项目部署到别人机器上执行完这一步一定要提醒自己旧数据已经没了。导入方式有两种。第一种是命令行适用于装了 MySQL 但没装图形工具的环境mysql -u root -p warehouse.sql回车后输入数据库密码如果看到一堆Query OK输出脚本就执行成功了。接着登录进去验证一下表是否都能查到mysql -u root -p USE warehouse; SHOW TABLES;第二种是 IDEA 自带数据库工具这种方式在答辩现场演示时特别能加分。打开 IDEA 右侧的 Database 面板点 “” 添加 MySQL 数据源填好主机、端口、用户名、密码测试连接成功后用鼠标右键点击数据库连接的Console窗口直接把warehouse.sql文件拖进去选中全部内容点执行按钮。等下方的日志跑完刷新表列表就能看到新表出现。我一般会推荐后者因为答辩时老师能看到你“用 IDEA 连接数据库并执行脚本”这一套操作比纯命令行演示更好理解。执行完后记得顺手在数据库中查一下初始化数据SELECT * FROM sys_user;确认admin账号存在说明脚本没白跑。3.3 脚本执行中的常见拦路虎字符集问题和版本差异导入脚本踩得最多的坑是 MySQL 5.x 和 8.x 的差异。脚本创建数据库时用的是utf8mb4这个字符集在 MySQL 8.x 是默认的没问题。但如果你的 MySQL 是 5.5 或更老版本utf8mb4支持不完整创建表时会报错。解决办法是手动把脚本里的utf8mb4替换成utf8但曹了之后如果商品名称里有 emoji 表情字符还是会有问题——不过仓库管理系统一般存不了 emoji所以可以用utf8兜底。另外注意检查脚本里的建表语句是否带ENGINEInnoDB。InnoDB 支持事务和外键仓库系统的出入库操作强依赖事务如果建表语句用的是默认 MyISAM会导致事务注解Transactional失效库存扣减出现一半成功一半失败的情况。看到这行就放心没有就需要手动加。执行完脚本后还有一步容易被忽略确认连接数据库的用户名和密码在application.yml里对得上。脚本本身没问题但application.yml里的password如果是root而你的 MySQL 密码是123456启动照样连不上。4. 核心模块的关键代码登录、商品管理、出入库毕设答辩时老师看代码不会从头看到尾他主要盯三个地方登录有没有做拦截、增删改查是否完整、出入库操作有没有考虑数据一致性。这一章的代码片段都是从项目里抽出来的核心逻辑看懂这三个模块整个项目就掌握一半了。4.1 登录模块拦截器是重点别只用 Session 做判断很多仓库管理系统的登录逻辑写得非常简单用户提交用户名密码数据库查到了就存 Session然后跳转主页。但答辩老师经常会追问“系统里有的页面是管理员才能进的操作员不能进你怎么控制”这个时候你就要讲拦截器。登录接口本身用 Spring Boot 写很短Controller public class LoginController { Autowired private UserService userService; PostMapping(/login) public String login(String username, String password, HttpSession session) { User user userService.findByUsernameAndPassword(username, password); if (user ! null) { session.setAttribute(loginUser, user); return redirect:/dashboard; } return redirect:/login?errortrue; } }findByUsernameAndPassword是 Service 层的方法内部调用 Mapper 执行SELECT * FROM sys_user WHERE username ? AND password ?。这个写法项目里没问题但你要能说出来它的问题密码是明文存储一旦数据库泄露所有账号密码全暴露。答辩时提到这一点然后补充一句“生产环境会用 BCrypt 加密存储”就会显得你考虑得比较深入。真正体现工作量的是拦截器。拦截器的作用是用户没登录时直接输入/product/list这类地址是访问不了的会被拦回登录页。代码是这个思路public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { // 未登录, 重定向到登录页 response.sendRedirect(/login); return false; } // 管理员接口操作员角色不能访问 if (request.getRequestURI().startsWith(/admin) !ADMIN.equals(user.getRole())) { response.sendError(403, 权限不足); return false; } return true; } }配置拦截器在 Spring Boot 里实现WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /css/**, /js/**, /images/**); } }逻辑说明addPathPatterns(/**)表示拦截所有路径excludePathPatterns把登录页和静态资源放行不然 CSS 样式也被拦掉页面会变得很难看。参数说明/admin开头的接口只有ADMIN角色能访问这个判断写在拦截请求里比每个 Controller 里手动判断要整洁得多。答辩时老师问“权限控制怎么做的”你就指着这段代码说登录态靠拦截器把关角色权限在preHandle里判断这样所有需要登录的接口自动生效不用每个方法重复写权限检查。4.2 商品管理分页查询和模糊搜索是必讲点商品管理是仓库系统最基础的 CRUD直接写一个select * from product连表查询当然也能用但商品数量一多全表查询就会变慢。所以商品列表必须做分页而且至少要支持按商品名称或编号模糊搜索。MyBatis 分页最常见的做法是引入 PageHelper 插件在 Service 层调用它方法很简洁Service public class ProductServiceImpl implements ProductService { Autowired private ProductMapper productMapper; public PageInfoProduct list(Product query, int pageNum, int pageSize) { // 开启分页, 紧跟着的下一句查询会被自动拼接 LIMIT PageHelper.startPage(pageNum, pageSize); ListProduct list productMapper.selectByCondition(query); return new PageInfo(list); } }PageHelper.startPage(pageNum, pageSize)是 PageHelper 的核心方法它利用 MyBatis 拦截器机制在执行下一条 SELECT 语句时自动拼上LIMIT子句。注意它必须紧挨着查询语句中间隔着别的查询会导致分页失效——这是个高频翻车点。PageInfo对象里封装了总条数、总页数、当前页数据列表前端 Thymeleaf 模板直接迭代它就能渲染出表格。对应的 Mapper XML 里要写动态 SQL按条件拼接模糊搜索select idselectByCondition resultTypecom.example.warehouse.entity.Product SELECT id, product_code, product_name, spec, unit, category FROM product where if testproductName ! null and productName ! AND product_name LIKE CONCAT(%, #{productName}, %) /if if testproductCode ! null and productCode ! AND product_code LIKE CONCAT(%, #{productCode}, %) /if /where ORDER BY id DESC /selectwhere标签的作用是自动去掉 SQL 里的第一个多余AND如果你在if里写了AND product_name LIKE...当没有其他条件时where会自动把开头的AND吃掉避免生成WHERE AND product_name...这种语法错误。CONCAT(%, #{productName}, %)是模糊匹配的惯用写法比直接在 Java 里拼好%xxx%再传进去更安全能有效避免 SQL 注入的风险。答辩老师问到“怎么防 SQL 注入”这就是一个标准回答。4.3 出入库与库存更新事务注解是底线出入库是仓库系统里最有含金量的模块因为每一步操作都涉及多张表的数据变更。拿入库举例填写入库单商品数量要加进库存表而且必须在同一个事务里完成否则就会出现“单据存上了库存没变”的诡异情况。入库的业务逻辑拆开是三步往inbound_bill里插入一条入库单记录检查stock表里有没有这个商品的库存记录存在则更新数量不存在则插入新记录。用代码表示就是这样Service public class InboundServiceImpl implements InboundService { Autowired private InboundBillMapper inboundBillMapper; Autowired private StockMapper stockMapper; Override Transactional(rollbackFor Exception.class) public void inbound(InboundBill bill) { // 1. 插入入库单 inboundBillMapper.insert(bill); // 2. 尝试更新库存, 返回受影响行数 int rows stockMapper.increaseStock(bill.getProductId(), bill.getQuantity()); if (rows 0) { // 库存记录不存在, 插入新库存记录 stockMapper.initStock(bill.getProductId(), bill.getQuantity()); } } }关键点全在Transactional(rollbackFor Exception.class)这个注解上。它告诉 Spring这个方法里任何一个数据库操作抛异常前面的操作全部回滚。不写这个注解如果insert成功而increaseStock失败库存就永远差了一截。rollbackFor Exception.class表示所有异常都触发回滚默认情况下 Spring 只对运行时异常回滚遇到 checked exception 不会回滚显式指定这个参数是一个好习惯。increaseStock对应的 SQL 是库存更新的核心UPDATE stock SET quantity quantity #{quantity}, update_time NOW() WHERE product_id #{productId}这里直接使用quantity quantity #{quantity}进行增量更新而不是先SELECT查到旧值再算新值UPDATE。原因很简单高并发下两个用户同时入库都查出quantity10各自加 5 后写回最后结果是 15 而不是 20这就是经典的丢失更新问题。用单条 SQL 把读和写放在同一个原子操作里数据库行锁会保证只有一个事务能执行成功。同样地出库时要在WHERE里加一个判断库存充足的子句不够就直接不更新UPDATE stock SET quantity quantity - #{quantity} WHERE product_id #{productId} AND quantity #{quantity}这里返回 0 就说明库存不够或商品不存在Service 层捕获这个结果后向外抛业务异常提示“库存不足”。出库单的插入和库存的扣减发生在同一个事务里保证不会出现“货出了库存没扣”的情况。5. 部署运行避坑从乱码到事务失效的排查记录项目能跑起来不稀奇能在答辩现场跑起来才叫本事。每年都有学生因为环境问题在台上翻车有些问题玄学得像撞鬼实际排查下来都是很基础的配置失误。这一章专门把高频坑位列出来每条都是现象、原因、解决三步走你照着检查就能少走很多弯路。5.1 页面数据全是问号数据库连接字符串少了参数现象后台商品列表里的中文全部显示成问号“???”但数据库工具里看数据是正常的。原因这是字符集不匹配。数据库创建时用了utf8mb4但 JDBC 连接字符串里没有声明characterEncodingutf8mb4导致 Java 程序与 MySQL 之间传输中文时使用了默认的latin1编码中文变成乱码写入数据库。解决打开application.yml在数据库连接的 URL 上加参数spring: datasource: url: jdbc:mysql://localhost:3306/warehouse?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai修改后重启项目重新执行一次插入操作验证。如果是已经写入的乱码数据光改配置不够要清掉那张表重新初始化数据或者执行UPDATE product SET product_name CONVERT(product_name USING utf8mb4)之类恢复但最省事的做法还是重新跑一遍 SQL 脚本。5.2 启动报端口被占用项目起不来现象启动 Spring Boot 时控制台提示Port 8080 was already in use项目直接退出。原因本地有其他程序占用了 8080 端口常见的是之前启动的 Tomcat 没关干净或者别的开发项目占着。解决两步选其一。第一改端口在application.yml里把server.port换成 8081 再启动。第二杀掉占用端口的进程macOS 和 Linux 用lsof -i:8080查 PIDWindows 用netstat -ano | findstr 8080查 PID然后taskkill /PID 进程号 /F强制结束。我一般优先改端口因为最省事且不会误杀别的服务。5.3 PageHelper 分页失效查出了全部数据现象商品列表页面一次性显示所有商品没有分页效果控制台日志里发现执行的 SELECT 语句没带 LIMIT。原因PageHelper.startPage()只对紧接着的第一条 SQL 查询生效。业务代码里如果在startPage和productMapper.selectByCondition()之间插入了其他查询比如先查了一次用户表获取当前操作人分页就串到那条 SELECT 上去了商品查询反而没分页。解决保证startPage后紧跟目标查询语句。如果确实需要先查用户信息把查用户的动作放到分页查询之后或者用两个独立的 Mapper 方法避免 PageHelper 拦截错 SQL。5.4 商品删除失败提示违反外键约束现象在商品管理页面删除某个商品后端报Cannot delete or update a parent row: a foreign key constraint fails删不掉。原因stock表、inbound_bill表通过外键引用了product表的主键。商品已经产生过入库或出库记录存在关联数据数据库拒绝删除被引用的父表记录。解决根据项目设计选一种方案。方案一是删除商品前先删除关联数据但这会把历史单据也删掉不利于审计跟踪不推荐。方案二是在product表中加一个status字段用逻辑删除代替物理删除商品下架只是把status改成0列表查询默认过滤掉下架商品历史单据数据完整保留。这个做法值得在答辩时主动提——因为老师说“你删除商品时历史数据怎么处理”时你能答出逻辑删除说明考虑到了业务可持续性比“删不掉就把外键去掉”的回答高一个层次。5.5 明明写了 Transactional数据却只更新了一半现象入库操作后入库单有记录但库存数量没变。代码里明明已经加上了Transactional注解检查几遍也没发现少了什么。原因最常见的是 Service 内部方法调用导致事务失效。很多源码在同一个类里方法 A 没加事务、方法 B 加了事务A 内部直接调用 this.B()事务注解不生效因为 Spring 的事务代理是通过 AOP 动态代理实现的this.B()走的是当前对象而不是代理对象事务控制整个就没生效。解决把加了事务注解的方法单独放进另一个 Service 类或者用Autowired注入自身代理对象但最稳妥的做法是事务控制发生在 Controller 调用的最外层 Service 方法上不要在 Service 内部自己调自己。更基础的一个校验是确认建表引擎是 InnoDB。MySQL 里 MyISAM 引擎不支持事务Transactional对它毫无意义表结构是 MyISAM 的话所有事务相关操作都是摆设。6. 低成本给毕设加亮点低库存预警与操作日志项目做完能跑只是及格线。答辩现场想拿高分就要在“基础功能之外你做了什么”上面做文章。加复杂功能风险大万一演示时出 bug 反而扣分。更稳妥的做法是挑两个实现简单、演示直观的小功能补充进去让老师一看就觉得你动了脑筋。第一个值得加的是低库存预警。需求描述很简单商品库存低于阈值时在首页醒目位置提示。实现只需要两步。第一步在stock表加字段warn_quantity INT DEFAULT 10第二步写一条查询 SQL查出所有库存小于等于预警值的商品SELECT p.product_code, p.product_name, s.quantity, s.warn_quantity FROM stock s LEFT JOIN product p ON s.product_id p.id WHERE s.quantity s.warn_quantity ORDER BY s.quantity ASC用户在首页登录后Controller 调用这个方法把结果放进 Model页面顶部循环显示“商品编号、名称、当前库存、预警阈值”。演示时你把某个商品的库存手动改成低于阈值刷新页面预警信息立刻出现这比任何口头描述都有说服力。第二个值得加的是操作日志。用 AOP 切面统一记录谁在什么时候做了什么事代码量不大答辩却能吹很久。核心概念是定义一个Log注解标注在需要记录的方法上再用Aspect切面拦截Aspect Component public class LogAspect { Autowired private SysLogMapper sysLogMapper; Around(annotation(com.example.warehouse.annotation.Log)) public Object recordLog(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); Object result pjp.proceed(); // 执行原方法 long cost System.currentTimeMillis() - start; String methodName pjp.getSignature().getName(); // 简化处理: 登录用户名从 Session 或参数里取, 这里用测试值示意 sysLogMapper.insert(new SysLog(admin, methodName, cost ms)); return result; } }Around环绕通知在目标方法执行前后都能切入pjp.proceed()放行原方法方法执行完拿到耗时再写入日志表。实际项目里用户名一般用RequestContextHolder从 Session 里取代码不够优雅但足够演示。做完后在入库、出库这两个关键方法上加上Log注解演示效果立刻不一样操作完打开日志页面能看到入参、操作人、耗时完整的操作链就出来了。最后一个验证技巧也是我每次做完项目都会执行的“一致性自检”。仓库系统的核心是数据对得上写段 SQL 把所有商品的理论库存和实际库存对比一遍SELECT p.id, p.product_name, COALESCE(s.quantity, 0) AS current_stock, COALESCE(i.total_inbound, 0) - COALESCE(o.total_outbound, 0) AS theoretical_stock FROM product p LEFT JOIN stock s ON p.id s.product_id LEFT JOIN (SELECT product_id, SUM(quantity) AS total_inbound FROM inbound_bill GROUP BY product_id) i ON p.id i.product_id LEFT JOIN (SELECT product_id, SUM(quantity) AS total_outbound FROM outbound_bill GROUP BY product_id) o ON p.id o.product_id;跑出来如果两列数字不一致就说明有某次入库或出库只写了单据没更新库存。这个问题一旦存在答辩演示做到后面数据会越看越不对趁早用这条 SQL 查出来修掉才敢说项目是“能交付”的状态。这是我自己做仓库类项目必跑的最后一道检查希望你也能用上希望帮到你。本文还有配套的精品资源点击获取