SSM框架打造超市进销存系统:从数据库设计到库存预警实战

发布时间:2026/10/31 10:17:26
SSM框架打造超市进销存系统:从数据库设计到库存预警实战

简介:一份基于SSM的宜佰丰超市进销存管理系统毕业设计源码包,主要面向计算机相关专业正在做毕设的学生,以及需要项目实战练习的Java学习者,也可用于课程设计或期末大作业。系统分为管理员、员工、普通用户三类角色:管理员可管理用户、员工、商品分类、商品信息、商品维护、出入库记录、销售统计、留言板、订单等;员工侧重商品管理和出入库、订单处理;前台支持商品浏览、新闻资讯、留言反馈和购物车。整包共1391个文件、64.48MB,以Java源码、JSP页面、JS/CSS前端脚本、图片素材和SQL脚本为主,并附带数据库脚本、开发说明文档、答辩PPT、演示视频和代码注释。项目已严格调试,可正常启动,需使用MySQL 5.7导入数据库脚本,并配置JDK1.8、Tomcat7运行环境。当前已有130人学习浏览,适合需要直接复用或二次开发的毕业设计场景。

1. 基于SSM的宜佰丰超市进销存管理系统:这个项目到底在解决超市的什么痛点

宜佰丰超市进销存管理系统,拆开来看就是“SSM(Spring + SpringMVC + MyBatis)+ 超市进销存业务”。线下超市最容易翻车的地方不是卖货,而是月底对账:进货单记了、收银系统走了、库存表却还停留在上周的数字。这个系统的核心价值,是把商品档案、供应商、进货入库、销售出库、实时库存全部串成一条数据链路,让每一件商品的进出都有据可查,账面库存和实物盘点能对得上。它适合三类人:准备做JavaWeb课设的在校生、想系统梳理SSM框架的中级开发,以及门店规模不大但Excel已经管不住库存的小老板。下面我按自己复现这类项目的完整路径,把框架选型、表结构、配置、代码和踩坑一次讲透。

2. 为什么选SSM而不是Spring Boot:进销存系统的选型逻辑

2.1 三层架构怎么各司其职:Controller、Service、Dao的边界

SSM给进销存系统带来的最大价值不是某个功能,而是清晰的分层约束。Controller只做参数接收和结果返回,不碰SQL;Service层包住业务规则,比如进货单录入后必须同时更新库存,这两步要么都成功要么都失败;Dao层也就是MyBatis的Mapper,只负责和数据库对话。我见过不少把SQL直接写在Controller里的项目,当时觉得方便,后面加一个“进货单审核”功能就要改三处,这就是分层没立住的后遗症。

在SSM里,这三个层次的代码位置也是约定俗成的:controller包放页面跳转和接口入口,service包放业务逻辑,mapper包放MyBatis的接口,对应的XML文件放在resources/mapper下。进销存系统的核心业务——进货入库、销售出库、库存预警——都是跨表的,比如销售出库要扣库存、要记销售明细、要更新商品销量,如果不在Service层做事务控制,任何一步失败都会让账面和实物对不上。

2.2 一次进货请求在SSM里走多远:从JSP页面到数据库的完整链路

拿“进货入库”这个功能举例,用户在前端页面填好商品、数量、进价,点击提交后,这条请求的完整路径是这样的:JSP页面发出POST请求到DispatcherServlet,SpringMVC根据@RequestMapping注解找到对应的Controller方法;Controller把请求参数封装成PurchaseBill对象,调用Service接口;Service实现类里先插入进货单主表,再循环插入进货明细表,最后更新库存表;MyBatis通过Mapper接口动态生成SQL完成数据库操作,事务提交后返回结果。整个过程看起来长,但每一层只干一件事,出了问题也容易定位。

这条链路上有四个注解是SSM项目里最高频的,也是很多人面试时被问到的:@Controller标记控制器、@Service标记业务类、@Autowired做依赖注入、@Transactional声明事务。Spring容器启动时会扫描这些注解,把对象的创建和依赖关系接管过来,这就是控制反转(IoC)。你不在代码里new对象,而是告诉Spring“我需要一个PurchaseService”,Spring会从容器里给你一个可用的实例。

2.3 选SSM还是Spring Boot:进销存这种业务系统怎么权衡

现在很多新项目直接上Spring Boot,但SSM在课设和老项目中仍然大量存在,原因有三点:第一,SSM的XML配置是显式的,数据源、事务、拦截器都能看得见,对理解框架原理帮助很大;第二,MyBatis的SQL是手写的,进销存这类查询条件多变的业务(按商品名、供应商、时间段、入库单号组合查)用动态SQL比JPA更方便;第三,很多学校和企业内部系统还在维护SSM代码,能看懂SSM的人接手成本更低。

如果你对比的是“基于Spring Boot + Vue的商品管理系统”这类方案,差别主要在两点:Boot把自动配置做到极致,省去了大量XML,适合快速搭建;Vue则把前端从JSP换成了前后端分离。但SSM这套“服务端渲染 + 表单直接提交”的玩法,在局域网内的小型进销存场景里反而更简单——不用处理跨域,不用单独部署前端,一台Tomcat就能跑完。我的建议是:做课设、学原理、维护老系统就选SSM;从头做商业项目且不要求手写SQL才考虑Boot。进销存的核心是库存准确性,和框架新老没有必然关系。

3. 先把数据库设计做对:进销存系统的核心表结构与建表SQL

3.1 业务规则先定清楚:一单一据、主明细分离、库存表怎么存

进销存系统的表结构设计,第一步不是画ER图,而是和业务方确认三条规则。第一,进货和销售都要“一单一据”,一张进货单包含多个商品,所以必须有主表和明细表两张表,主表存单号、供应商、日期、总金额,明细表存每一行商品的数量和进价。第二,库存表只存当前实时库存,不做历史流水,历史进出记录由进货明细和销售明细去追溯。第三,库存字段用DECIMAL而不是FLOAT,钱和数量都不能有浮点误差。

主明细分离的设计在进销存里几乎是必须的,因为查“某张进货单有哪些商品”和查“某商品最近从哪些供应商进过货”是两种完全不同的查询维度。如果只建一张大宽表,主单信息在每一行里重复存储,修改供应商名称时就要UPDATE几十行,而且数据冗余会导致统计口径混乱。主表、明细表各司其职,单号作为外键关联,这是进销存系统的经典设计。

3.2 建表SQL落地:商品、供应商、进货、销售、库存、用户全量脚本

下面是我搭建同类项目时使用的MySQL建表脚本,覆盖了进销存的最小闭环。你可以直接复制到Navicat或命令行执行,注意表的创建顺序:先建不依赖外键的基础表,再建业务表和中间表。

-- 商品表:存放商品基础档案 CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID', product_name VARCHAR(100) NOT NULL COMMENT '商品名称', barcode VARCHAR(32) DEFAULT NULL COMMENT '条码', category VARCHAR(50) DEFAULT NULL COMMENT '分类', sell_price DECIMAL(10,2) NOT NULL COMMENT '零售价', unit VARCHAR(10) DEFAULT '件' COMMENT '单位', warn_low INT DEFAULT 10 COMMENT '库存预警下限', warn_high INT DEFAULT 9999 COMMENT '库存预警上限', status TINYINT DEFAULT 1 COMMENT '1上架 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode) ) COMMENT='商品表';
-- 供应商表 CREATE TABLE supplier ( supplier_id INT PRIMARY KEY AUTO_INCREMENT, supplier_name VARCHAR(100) NOT NULL, contact_person VARCHAR(50), phone VARCHAR(20), address VARCHAR(200), status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT='供应商表';
-- 进货单主表 CREATE TABLE purchase_bill ( bill_id INT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL COMMENT '进货单号', supplier_id INT NOT NULL, total_amount DECIMAL(12,2) DEFAULT 0 COMMENT '总金额', bill_status TINYINT DEFAULT 1 COMMENT '1已入库 0作废', create_user VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_bill_no (bill_no), KEY idx_supplier (supplier_id) ) COMMENT='进货单主表';
-- 进货单明细表 CREATE TABLE purchase_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, bill_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL COMMENT '进货数量', purchase_price DECIMAL(10,2) NOT NULL COMMENT '进货单价', KEY idx_bill (bill_id), KEY idx_product (product_id) ) COMMENT='进货单明细表';
-- 销售单主表 CREATE TABLE sale_bill ( bill_id INT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL, total_amount DECIMAL(12,2) DEFAULT 0, pay_type TINYINT DEFAULT 1 COMMENT '1现金 2微信 3支付宝', create_user VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_bill_no (bill_no) ) COMMENT='销售单主表';
-- 销售单明细表 CREATE TABLE sale_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, bill_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, sale_price DECIMAL(10,2) NOT NULL, cost_price DECIMAL(10,2) DEFAULT 0 COMMENT '进货成本,用于毛利计算', KEY idx_bill (bill_id), KEY idx_product (product_id) ) COMMENT='销售单明细表';
-- 库存表:只存当前实时库存 CREATE TABLE stock ( stock_id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_product (product_id) ) COMMENT='库存表';
-- 用户表 CREATE TABLE sys_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT '建议存MD5或BCrypt', real_name VARCHAR(50), role TINYINT DEFAULT 1 COMMENT '1管理员 2收银员 3仓管', status TINYINT DEFAULT 1 ) COMMENT='系统用户表';

3.3 字段设计的细节:DECIMAL精度、唯一键、逻辑删除一个都不能省

上面这段SQL里隐藏着几个容易忽略的细节,值得逐个说明。第一,金额和数量全部用DECIMAL,进货价、零售价、库存数量都不允许出现“1.999999”这种浮点误差,这是财务对账的底线。第二,进货单号和销售单号都加了UNIQUE KEY,业务上单号不允许重复,如果应用层重复提交,数据库会直接报错兜底。第三,商品表的条码字段也加了唯一键,同一条码对应一种商品,这是超市扫码收银的前提。

关于逻辑删除,我的习惯是基础资料表(商品、供应商)加status字段做停用/启用,而不是物理删除;但进货单、销售单这类流水表一旦产生,通常不允许删除,只能做“作废”标记。这样设计的好处是历史数据永远可追溯,对账时即使发现某张单子有问题,也可以通过bill_status='0'把它排除在有效统计之外,而不是彻底找不到这张单子。

4. 在IDEA里把SSM项目跑通:配置、代码与三个常用注解的落地

4.1 Maven项目结构与依赖:搭建SSM架构的最少文件清单

从零搭建SSM项目时,我习惯用Maven管理依赖,Java版本用1.8,Web容器用Tomcat 8.5或9。项目结构上,源码放在src/main/java,配置文件放在src/main/resources,JSP页面放在src/main/webapp/WEB-INF/views下。进销存系统的页面不多,但不建议把JSP直接扔在webapp根目录,放在WEB-INF下可以禁止用户直接通过URL访问未登录页面。

pom.xml里需要引入的核心依赖是:spring-webmvc(SpringMVC)、spring-jdbc(事务)、mybatis与mybatis-spring(MyBatis集成)、mysql-connector-java(数据库驱动)、jackson-databind(返回JSON用)、jstl(JSP标签库)。如果你是第一次配,最容易出问题的是mybatis-spring和Spring版本的兼容性,我一般用Spring 5.2.x + MyBatis 3.5.x + mybatis-spring 2.0.x这个组合,这套搭配经过大量项目验证,坑最少。

4.2 三个核心配置文件:数据源、Spring容器、SpringMVC各管一摊

SSM的配置分散在三个文件里,各管一摊。第一个是jdbc.properties,只管数据库连接参数;第二个是spring-mybatis.xml,负责数据源、事务和MyBatis的整合;第三个是spring-mvc.xml,负责控制器扫描、注解驱动和视图解析。下面逐个贴出最小可用配置。

# jdbc.properties jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/yibaifeng?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456

这四行配置里最容易被忽略的是URL上的characterEncoding和serverTimezone参数。不带characterEncoding=utf-8,中文商品名入库就会变成问号;不带serverTimezone,MySQL 8.x会报时区错误。这两个参数是SSM连接MySQL的标配,少了任何一个都是玄学问题。

<!-- spring-mybatis.xml --> <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource"> <property name="driverClass" value="${jdbc.driver}"/> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="user" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialPoolSize" value="5"/> <property name="maxPoolSize" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.yibaifeng.pojo"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.yibaifeng.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

数据源这里用c3p0连接池,initialPoolSize和maxPoolSize分别是初始连接数和最大连接数。超市收银系统的并发量不会太高,5到20的连接数完全够用,但连接池必须配,否则每次请求都新建数据库连接会拖垮Tomcat。sqlSessionFactory里的mapperLocations告诉MyBatis去哪里找SQL映射文件,typeAliasesPackage让Mapper返回值可以直接写类名,不用每次都写全限定名。

<!-- spring-mvc.xml --> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <context:component-scan base-package="com.yibaifeng.controller"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>

spring-mvc.xml里最关键的是mvc:annotation-driven和mvc:default-servlet-handler这两行。前者启用SpringMVC的注解支持,让@RequestMapping、@ResponseBody生效;后者把静态资源(CSS、JS、图片)的请求交回Tomcat默认处理器,否则登录页的CSS样式会被DispatcherServlet拦截导致页面裸奔,这个问题在SSM项目里非常常见。

4.3 进货入库的完整链路:Controller、Service、Mapper各写什么

配置配好之后,代码实现就是顺着SSM的固定套路走。进货入库是进销存里最核心的功能,我以它为例,把Controller、Service、Mapper三个层次的代码完整贴出来。首先是Controller层,负责接收页面表单并返回结果。

@Controller @RequestMapping("/purchase") public class PurchaseController { @Autowired private PurchaseService purchaseService; @RequestMapping("/add") @ResponseBody public Result add(@RequestBody PurchaseBill bill) { try { purchaseService.addBill(bill); return Result.success("入库成功"); } catch (Exception e) { return Result.error("入库失败:" + e.getMessage()); } } }

Controller的代码很薄,只做三件事:通过@Autowired拿到Service实例、调用业务方法、把结果包装成Result对象返回。@RequestBody的作用是把前端传来的JSON字符串直接绑定成PurchaseBill对象,前提是前端提交时设置了Content-Type为application/json,并且PurchaseBill类里嵌套了List 类型的items属性,否则会报“不能转换成PurchaseBill”的错误。

紧接着是Service实现类,这里要处理真正的业务规则:保存主单、保存明细、更新库存三步必须在一个事务里完成。

@Service public class PurchaseServiceImpl implements PurchaseService { @Autowired private PurchaseBillMapper billMapper; @Autowired private PurchaseItemMapper itemMapper; @Autowired private StockMapper stockMapper; @Override @Transactional(rollbackFor = Exception.class) public void addBill(PurchaseBill bill) { // 1. 生成单号并插入主表 bill.setBillNo(generateBillNo()); bill.setBillStatus(1); billMapper.insert(bill); // 2. 循环插入明细 for (PurchaseItem item : bill.getItems()) { item.setBillId(bill.getBillId()); itemMapper.insert(item); stockMapper.increaseStock(item.getProductId(), item.getQuantity()); } // 3. 更新商品表中的最近进价 for (PurchaseItem item : bill.getItems()) { productMapper.updateLastPurchasePrice(item.getProductId(), item.getPurchasePrice()); } } }

这段代码的逻辑说明:@Transactional(rollbackFor = Exception.class)是SSM项目里最核心的注解之一,它保证任何一步抛异常都会回滚所有已执行的SQL。rollbackFor指定只有Exception才回滚,默认配置下运行时异常才回滚,检查异常不会,这里显式写出来最安全。generateBillNo()是自定义方法,常见格式是“20240507 + 四位流水号”,生成时要注意并发重复,可以在SQL里用主键唯一键兜底。

Mapper接口和XML文件是SSM的最后一环。这一步是MyBatis的拿手活,库存变更的SQL要特别注意写法。

public interface StockMapper { int increaseStock(@Param("productId") Integer productId, @Param("quantity") Integer quantity); int decreaseStock(@Param("productId") Integer productId, @Param("quantity") Integer quantity); }
<!-- StockMapper.xml --> <update id="increaseStock"> UPDATE stock SET quantity = quantity + #{quantity} WHERE product_id = #{productId} </update> <update id="decreaseStock"> UPDATE stock SET quantity = quantity - #{quantity} WHERE product_id = #{productId} AND quantity >= #{quantity} </update>

increaseStock用“quantity = quantity + 传入数量”的方式做原子自增,不需要先查后改,也就避免了并发覆盖。真正值得关注的是decreaseStock,它在WHERE条件里加了一个quantity >= #{quantity}的约束——如果库存不够,这条UPDATE会返回影响行数为0,Service层就能据此判定“库存不足”并抛出异常。这是防超卖最简单有效的方式,比先SELECT再比较的写法可靠得多,因为SELECT和UPDATE之间可能插入其他事务,而一条UPDATE本身就是原子的。

4.4 销售出库的库存扣减:一条带条件的UPDATE解决并发超卖

销售出库对应的代码逻辑和进货入库基本对称,但多了一个毛利计算:销售明细表里既要存零售价,也要存当时的进货成本,这样月底才能算毛利。在Service层处理销售单时,常见做法是先查一次商品的当前成本价填到sale_item.cost_price里,再调用decreaseStock扣库存,最后更新库存表。

很多人在这一步会踩到“超卖”的坑:高并发下两个收银台同时卖出最后一件同一商品,两条decreaseStock同时执行,如果没有quantity >= #{quantity}这个条件,库存就可能变成负数。解决方式就是上面那段SQL,它在数据库层面做了约束,失败的那条UPDATE返回0行,Service层捕获后给出“库存不足”的友好提示,并把整个事务回滚,保证销售单不会生成。

5. 进销存系统避坑:五个让库存对不上账面、页面直接报错的高频问题

5.1 中文乱码:商品名变成“???”,页面提交全是问号

现象:从JSP页面提交商品名“洗发水”后,数据库里存成了“???”,控制台打印SQL时也能看到参数是问号。

原因:请求参数编码和数据传输编码不一致。Tomcat默认的POST请求编码是ISO-8859-1,而页面是UTF-8;同时jdbc连接串如果没带characterEncoding=utf-8,MySQL也会按默认字符集存数据。

解决:第一,在web.xml里配置CharacterEncodingFilter并设置forceEncoding为true,让所有请求都按UTF-8解码;第二,确保jdbc.url里带了characterEncoding=utf-8;第三,数据库表本身要建成utf8mb4。这三点缺一不可,只配一处往往还会零星出现乱码。

5.2 Invalid bound statement (not found):Mapper接口和XML对不上

现象:Tomcat启动后没报错,但一调用进货单查询接口就抛“Invalid bound statement (not found)”异常,页面直接500。

原因:MyBatis在启动时没有把XML映射文件绑定到Mapper接口上。常见原因是spring-mybatis.xml里的mapperLocations路径写错,比如XML实际在resources/mapper/下,配置写的却是classpath:mapperXml/*.xml。

解决:先看控制台启动日志里有没有“Parsed 0 mapper”之类的提示,如果解析到0个XML,就是路径问题。检查mapperLocations的路径和XML文件实际位置是否一致,同时确认Mapper接口和XML的namespace、statement id、返回类型都对得上,比如接口方法名是selectBillList,XML里的id就必须也是selectBillList。

5.3 库存越扣越少甚至变负数:超卖只靠代码判断不保险

现象:销售出库后库存数量偶尔对不上,周末高峰期甚至出现负库存,月底盘点差异很大。

原因:在Service层先查库存再比较、然后UPDATE的做法在低并发下没问题,但收银高峰期多个请求同时读到相同库存时,“检查通过”和“实际扣减”之间产生了空窗期,导致超卖。

解决:把检查能力下沉到SQL里,用UPDATE ... WHERE quantity >= #{quantity}的原子操作替代“先查后改”。应用层只根据影响行数判断是否扣减成功,影响行数为0就抛业务异常“库存不足”。这个做法在中小超市的并发量下足够可靠,同时保持了对数据库的兼容性。

5.4 进货单双击提交:一张单入库两次,库存多出一倍

现象:客户反馈库存盘点时发现某商品多出一倍,查进货明细发现同一单号存在两条几乎相同的数据,只有创建时间差了几百毫秒。

原因:用户连续点击“提交”按钮,两次请求都成功进入了Controller,Service层没有做幂等控制,两次插入走了不同的事务,数据库层的UNIQUE KEY没拦住——因为应用层生成的单号两次可能不同。

解决:前端在提交后立即禁用按钮,这是第一道防线;后端在Service层根据“供应商+进货日期+操作人”的一致性哈希生成幂等键,插入前先查是否已存在。更稳妥的做法是让单号由数据库序列或时间戳+随机数生成,给bill_no加UNIQUE约束,重复插入时数据库直接报错迫使其回滚。

5.5 页面样式全丢了,登录页裸奔无CSS:静态资源被DispatcherServlet拦截

现象:部署后访问登录页,HTML结构还在,但所有CSS、JS全部加载失败,F12看到style.css返回403或404。

原因:web.xml里把DispatcherServlet映射到了“/”,导致所有请求都进入SpringMVC,包括CSS、JS、图片这些静态资源,而SpringMVC默认没有处理静态资源的能力。

解决:在spring-mvc.xml里加 mvc:default-servlet-handler/ ,把静态资源请求交回Tomcat默认的DefaultServlet处理;同时确认JSP页面里引用静态资源的路径写的是上下文路径,比如${pageContext.request.contextPath}/css/style.css。另外,如果你引用了外部字体或图表库,注意这些资源也要能通过同样的路径访问到,否则页面在部分浏览器上会显示异常——这类问题往往比后端错误定位更费时间。

6. 进阶验证:给SSM进销存系统补上一套对账与预警机制

6.1 用一条SQL验证“账面库存和实物盘点是否一致”

进销存系统上线后最核心的验收标准,是拿一个时间点的期初库存,加上该时间段内的进货总数量,减去销售总数量,算出来的结果是否等于当前库存。下面这条SQL可以实现这个验算:

SELECT p.product_name, s.quantity AS stock_qty, IFNULL(pur.total_in, 0) AS total_purchase, IFNULL(sal.total_out, 0) AS total_sale, s.quantity - IFNULL(pur.total_in, 0) + IFNULL(sal.total_out, 0) AS diff FROM stock s JOIN product p ON p.product_id = s.product_id LEFT JOIN ( SELECT product_id, SUM(quantity) AS total_in FROM purchase_item GROUP BY product_id ) pur ON pur.product_id = s.product_id LEFT JOIN ( SELECT product_id, SUM(quantity) AS total_out FROM sale_item GROUP BY product_id ) sal ON sal.product_id = s.product_id;

这段SQL基于一个假设:初始化库存从0开始,所有入库都走进货单、所有出库都走销售单。diff字段如果为0,说明每张业务单据和当前库存完全吻合;如果某行diff不为0,就说明那张单据的库存更新逻辑有问题,需要回溯业务日志。我自己的习惯是把这条SQL放在一个只读接口里,盘点时直接调用,省去手工跑Navicat。

6.2 库存预警:低于下限自动提醒补货,高于上限提醒积压

库存预警功能不需要多复杂的架构,一个定时任务加一条查询就能做。用Spring的@Scheduled注解在Service层写一个每天早晨执行的任务,扫描所有低于商品表里warn_low字段的库存,把预警清单生成消息通知给采购员。金额和库存阈值的设定要区别对待:快消品(饮料、零食)下限可以放宽,单价高的商品(烟酒、进口食品)下限要收紧,避免资金被压在货架上。

做这个功能时我踩过一个细节坑:@Scheduled默认是单线程的,如果任务里不小心写了耗时很长的报表导出,预警就会延迟到报表跑完才执行。解决方式是加一个线程池配置,或者把预警任务独立成专门的Service,和业务主链路解耦。这个思路也提醒我:SSM项目里不是所有问题都要靠改代码解决,对上账、优化掉慢查询、把关键SQL看得懂,往往才是进销存系统稳定运行的真正命门。希望这些能帮到你,让你少走我当初走过的弯路。

本文还有配套的精品资源,点击获取