☰
SSM+Vue农产品进销存系统开发实战:从数据库设计到库存事务
2026/10/22 5:00:58 网站建设 项目流程

最近在整理SSM294这个农产品进销存管理项目,前后端代码都重新过了一遍,趁着热乎把积累下来的经验手记写成一篇完整的总结。这是一套非常典型的Java Web全栈项目,后端用Spring、SpringMVC、MyBatis,数据库用MySQL,前端用Vue加Element UI,目标场景是农产品批发、经销这类中小型业务,覆盖采购进货、销售出库、库存盘点、报表统计一整条业务链路。SSM294这个编号是项目仓库里的题目代号,294代表着这版题目的序号,从框架结构上看,它属于高校软件工程、信息管理类课程设计里最常见的那类管理系统题目。

这篇文章想聊的绝不是增删改查的页面演示,而是从数据库设计、后端业务接口到前端联调全过程里,真正需要想清楚的关键点。尤其会单独讲清楚农产品和普通工业品的差异——保质期、批次、计量单位换算、损耗管理,这些部分最容易在开发时被忽略,但恰恰决定了系统能不能真正落地使用。如果你正在做类似的SSM项目,或者正准备做进销存方向的系统开发,这篇内容可以直接当参考。

1.1 农产品进销存到底在做什么

先明确一个基本概念。进销存三个字分别对应采购入库、销售出库和库存管理,核心逻辑就是围绕“进货单”和“销售单”两类业务单据,产生每一笔库存变动流水,最终由库存把进出两端串联起来。系统虽然名字听着不复杂,但作为业务型管理系统,逻辑闭环比页面多少重要得多。

农产品和标准工业品不太一样,有几处特性直接决定了系统的设计方向。第一是计量单位不统一,土豆可以按斤卖,也可以按袋进货,鸡蛋按箱管理、按板销售,这需要在商品信息里预留多单位和换算比例。第二是价格波动明显,今天地头收购价和明天可能差一大截,所以每一笔进货都必须记录实际进价,不能用一个默认价糊弄过去。第三是保质期和损耗,水果蔬菜放几天就可能坏掉,系统要能感知批次入库时间和临期情况,不然库存页面就是一堆死数字。

给非技术读者打个比方,这套系统就像一个记账本加一个提醒器。记账本记清楚每一笔钱和货从哪里来、到哪里去,提醒器负责告诉你哪些货快卖完了、哪些货快放坏了。所有管理动作最终都落到库存账面变动上,所以库存表是本系统的核心表,所有其他业务最终都要和它发生关系。

1.2 为什么用SSM加Vue这套组合

讲实话,如果完全从零选型,我可能直接上Spring Boot加Vue3。但SSM294这套项目本身就是围绕SSM教学体系出的题目,Spring加SpringMVC加MyBatis的组合在课程设计、毕业设计场景下有着极高的适配性。一方面课程大纲就以SSM为主线,另一方面它的分层思想清晰,Controller、Service、Mapper三层结构能很好地帮助理解一个Web项目是怎么跑起来的,这也是很多评审老师看重的地方。

前端选择Vue的理由就更直接了。Vue的组件化开发让页面复用变得很轻松,配合Element UI组件库,写后台管理界面的效率很高。再加上Axios做前后端交互,整个开发体验比传统JSP直接渲染舒服太多。这套组合本质上是“经典后端加现代前端”的过渡方案,对于教学项目来说是最稳的选择,资料多、社区活跃、出了问题基本都能搜到解决方案。

提示:如果接手SSM294这类编号的项目,第一件事不是看业务代码,而是依次确认JDK版本、MySQL版本、Maven仓库能否正常拉依赖。这三个环境问题一旦不匹配,能消耗你小半天时间。

1.3 用户角色与功能模块划分

系统我拆成四类角色:管理员、采购员、销售员、仓储管理员。角色不同,菜单和按钮权限都不同。采购员只能操作进货单模块,销售员只管销售单模块,仓储管理员负责库存查询和盘点,管理员拥有全部功能并额外能看到统计报表。

具体功能模块大致是八个方向:登录与权限校验、供应商管理、客户管理、商品管理、进货管理、销售管理、库存管理、统计报表。前几个是基础档案,中间两个是日常业务单据,库存是业务单据的结果沉淀,报表则是给管理层看的决策数据。这套功能集合也基本构成了一个标准进销存系统的完整闭环。

2. 数据库表结构设计:进销存系统的地基

2.1 主表加明细表:进销存表结构的通用套路

进销存系统的数据库设计有一条很关键的原则:主表和明细表分开。所谓“一单多货”,一张进货单上往往有十几种农产品,不可能把每种商品都塞进主表字段,而是要拆成“单据主表”加“单据明细表”。主表记录单据的统一信息,比如单号、往来单位、日期、总金额,明细表记录每一行商品的明细,比如商品ID、数量、单价、小计。

我这套项目的核心表结构大致如下:

表名用途核心字段
t_user用户表用户名、密码、角色、状态
t_supplier供应商表名称、联系人、电话、地址
t_customer客户表名称、联系人、电话、地址
t_product商品表名称、分类、主单位、辅助单位、换算比例、预警库存
t_purchase进货单主表单号、供应商ID、进货日期、总金额、操作人
t_purchase_item进货明细表进货单ID、商品ID、数量、进价、小计
t_sale销售单主表单号、客户ID、销售日期、总金额、操作人
t_sale_item销售明细表销售单ID、商品ID、数量、销价、小计
t_stock库存表商品ID、当前库存数量、最近变动时间
t_stock_record库存流水表商品ID、变动类型、变动数量、关联单据号、变动时间

这个结构看起来表多,实际是标准做法。进货单和销售单各弄一套主明细结构,库存表和流水表单独成表,所有数量变动必须通过单据联动,不允许绕过单据直接改库存字段。这样做最大的好处是每一笔数量变化都能追溯来源,对账的时候直接查流水表就一目了然,不需要翻半天日志。

2.2 农产品特性的字段设计细节

农产品不是标准工业品,所以我在商品表里额外留了几个关键字段,包括主单位、辅助单位、换算比例、保质期天数、产地、批次号。这些字段在普通商品系统里需求没那么强烈,但放到农产品场景里每一个都很有价值。

从业务角度说,主单位和辅助单位解决的是“进货按箱、销售按盒”这类跨单位换算问题。比如一箱苹果重二十斤,商品表里unit_main存“斤”,unit_sub存“箱”,unit_rate存20.00。进货单录入时选箱,系统自动换算成斤累加进库存;销售出库时按斤扣减,两边完全对得上。没有这个设计的话,库存数量会出现“斤箱不分”的脏数据。

批次号字段则是为后续做先进先出和保质期管理预留的空间。同一款农产品,不同批次的进货价可能不同,成熟度、新鲜度也有差别。如果所有批次混在一起用同一个均价,毛利统计会和真实情况有出入。我目前这个版本的库存扣减还没有做到完全按批次消耗,但表结构预留了batch_no字段,后续要扩展时直接基于这个字段开发就行。

库存预警字段也特别重要。我在商品表里放了warn_stock,当实时库存小于等于这个阈值时,首页和库存列表里直接标红提示。当初我做到这个功能后,才真正感觉系统从“被动记数量”升级成了“主动提业务风险”,农产品最怕积压,一个能提前提示补货和清库存的系统,对实际运营的帮助非常大。

建表SQL我精简一下核心表,方便直接参考:

create table t_product ( id int primary key auto_increment, product_name varchar(64) not null, category varchar(32), unit_main varchar(16) not null, unit_sub varchar(16), unit_rate decimal(8,2) default 1, shelf_days int default 0, produce_place varchar(64), batch_no varchar(32), warn_stock decimal(12,2) default 0, create_time datetime default current_timestamp ) engine=innodb default charset=utf8mb4;

一直建议把默认字符集直接定为utf8mb4而不是utf8。utf8是3字节编码,遇到生僻字或者一些特殊标识符号会直接报错,农产品名称里偶尔会有地区生僻字,换成utf8mb4省心得多。

2.3 单据号生成策略

单据号这个不起眼的细节,我反反复复改了三版。最开始用的是数据库自增ID直接当单号,后来模拟实际业务时发现,打出来的进货单如果只有个数字ID,客户根本看不出来是哪天的单、是哪个渠道进的货。最后改成“前缀加日期加三位流水号”的格式,例如采购单PO20250116001,销售单SA20250116001,一眼就能看出业务类型和日期。

生成逻辑如下:

String prefix = "PO" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String maxNo = purchaseMapper.findMaxNoByPrefix(prefix); int seq = maxNo == null ? 1 : Integer.parseInt(maxNo.substring(prefix.length())) + 1; String billNo = prefix + String.format("%03d", seq);

这个方案的好处是不需要单独维护序号表,单机业务场景下完全够用。需要注意findMaxNoByPrefix字段要加索引,不然单量增长之后查询会变慢,单据号生成的接口也会跟着变慢。

2.4 库存预警与临期提醒的查询思路

临期提醒的业务实现也很直接。商品表里的shelf_days保存保质期天数,入库时在库存流水里记录入库日期,通过计算“入库日期加上保质期天数是否接近当前日期”来筛选临期商品。库存预警则更简单,一条SQL就能出结果:

select p.product_name, p.unit_main, s.quantity, p.warn_stock from t_stock s left join t_product p on s.product_id = p.id where s.quantity <= p.warn_stock;

预警这个功能做出来之后,整个系统的价值感会上升一个档次。因为进销存系统本质上不只是记录历史,而是要辅助运营决策,库存预警和临期报警正好是这一层价值最直观的体现。

3. 后端SSM实现:从接口到库存事务

3.1 分包结构与统一返回机制

一个标准的SSM项目,分包结构一定要清晰。我的项目按controller、service、mapper、entity、common、config分层。Controller层只做参数接收和结果封装,所有业务判断都放到Service层,Mapper层只负责数据库操作。这个分层原则是SSM项目的基本素养,哪怕是一个简单的登录逻辑,也不要直接在Controller里写SQL,后面维护会非常痛苦。

前后端分离之后,接口返回格式必须统一,否则前端处理起来就是灾难。我定义了一个Result类,所有Controller方法统一返回这个结构,核心代码大致如下:

public class Result<T> { private int code; private String message; private T data; // 省略getter/setter }

约定code等于200表示成功,非200表示业务异常,前端Axios拦截器统一判断code。这样做的直接好处是前端不需要为每个接口单独写错误分支,后端加参数校验、审计字段也有统一出口。

全局异常处理用@ControllerAdvice统一收口:

@ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseBody public Result<String> handle(Exception e) { e.printStackTrace(); return Result.error(500, "系统异常:" + e.getMessage()); } }

这段代码不长,但它是整个项目里最实用的基础设施之一。没有它,前端接口一旦出错就会拿到各种不成格式的异常报文,联调阶段会浪费大量时间。

3.2 采购入库:事务与库存更新的核心逻辑

采购入库是进销存系统里业务逻辑最完整的一个操作。一次入库要做三件事:创建进货单主记录、循环保存进货明细、更新库存表并插入库存流水。这三件事必须在一个事务里,任何一步失败都要回滚,不然会出现“单据有了但库存没变”或者“库存变了但查不到来源”的脏状态。

核心代码分成两层。Service层的addPurchase负责业务编排:

@Transactional(rollbackFor = Exception.class) public void addPurchase(PurchaseDTO dto) { Purchase purchase = new Purchase(); purchase.setBillNo(generateBillNo()); purchase.setSupplierId(dto.getSupplierId()); purchase.setPurchaseDate(dto.getPurchaseDate()); purchase.setTotalAmount(dto.getTotalAmount()); purchase.setOperator(loginUserId()); purchaseMapper.insert(purchase); for (PurchaseItemDTO item : dto.getItems()) { PurchaseItem pi = new PurchaseItem(); pi.setPurchaseId(purchase.getId()); pi.setProductId(item.getProductId()); pi.setQuantity(item.getQuantity()); pi.setPrice(item.getPrice()); purchaseItemMapper.insert(pi); stockService.addStock(item.getProductId(), item.getQuantity(), "PURCHASE", purchase.getBillNo()); } }

stockService里的addStock负责库存变动,并写在同一个事务传播范围内:

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public void addStock(Integer productId, BigDecimal quantity, String type, String refNo) { Stock stock = stockMapper.findByProductId(productId); if (stock == null) { stock = new Stock(); stock.setProductId(productId); stock.setQuantity(quantity); stockMapper.insert(stock); } else { stock.setQuantity(stock.getQuantity().add(quantity)); stockMapper.updateById(stock); } stockRecordMapper.insert(buildRecord(productId, quantity, type, refNo)); }

这里要特别强调rollbackFor=Exception.class这个参数。很多初学SSM的项目里只写@Transactional不指定回滚条件,这样遇到受检异常时事务并不会自动回滚,导致数据只写了一半。这个问题在项目评审和真实运行中都容易被翻出来,属于必须提前规避的经典坑。

销售出库是反向操作,逻辑和采购入库类似,但多了库存校验,不允许超卖。这一块的并发处理方案放到第5章专门说明,因为那是整个系统最容易出bug的地方。

3.3 查询与分页的动态SQL细节

进销存系统里,列表查询基本都是多条件模糊搜索。MyBatis动态SQL在这种场景下是绝对主力。以进货单列表查询为例:

<select id="findPage" resultType="com.example.fms.entity.Purchase"> select * from t_purchase <where> <if test="billNo != null and billNo != ''"> and bill_no like concat('%', #{billNo}, '%') </if> <if test="supplierId != null"> and supplier_id = #{supplierId} </if> <if test="startDate != null"> and purchase_date &gt;= #{startDate} </if> <if test="endDate != null"> and purchase_date &lt;= #{endDate} </if> </where> order by create_time desc </select>

有一点要提醒,XML里日期字段比较时,大于号小于号一定要写成>和<,直接用>符号的话XML解析直接报错。这是非常低级的错误,但实际开发里几乎人人都踩过。

分页我用的是PageHelper插件,使用姿势是在查询代码前调用PageHelper.startPage:

PageHelper.startPage(pageNum, pageSize); List<Purchase> list = purchaseMapper.findPage(query); PageInfo<Purchase> pageInfo = new PageInfo<>(list);

这个插件用起来简单,但有一个隐藏的大坑。PageHelper基于ThreadLocal保存分页参数,startPage调用之后必须紧跟要分页的查询,中间如果再执行任何其他数据库操作,分页参数会被那个操作“吃掉”,最终导致真正要分页的查询没有分页效果。这条经验是真实调试中踩过的,代码review时我基本都会重点盯这一处。

4. 前端Vue实现与接口联调要点

4.1 前端项目搭建与路由守卫

前端用Vue CLI搭建项目,配合Vue Router和Element UI,页面入口结构大致是登录页、首页工作台、商品管理、供应商管理、客户管理、进货管理、销售管理、库存管理、统计报表几个模块。路由层面通过全局前置守卫做登录拦截,逻辑很简单:每次路由跳转前检查本地是否有token,没有就强制跳回登录页。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })

这里Token的存放方式在真实系统里可以讨论,生产环境一般放Cookie配合服务端Session或者用HttpOnly保护,但SSM294这个课程项目场景下,LoacalStorage配合接口Authorization头已经能够完整演示权限控制流程,同时也方便评审老师理解。

4.2 Axios封装与接口对接

Axios封装是整个前后端联调的命脉。我在utils/request.js里做了一层统一拦截,核心代码如下:

import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { alert(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } )

这段封装有一个有意思的细节:响应拦截器拿到后端Result结构后,判断code为200就直接返回res.data,这样调用方拿到的就是业务数据本身,不会再出现res.data.data这种多层嵌套的诡异写法,整个业务代码会清爽很多。401状态码统一处理成跳转登录页,也省得每个页面单独判断登录过期。

4.3 商品管理页面的骨架套用

进销存系统的页面形态高度相似,基本都是“顶部筛选栏加中间表格加弹窗表单”的标准组合。以商品管理页面为例,模板结构大致如下:

<template> <div class="product-page"> <el-form :inline="true"> <el-form-item label="商品名称"> <el-input v-model="queryForm.keyword" placeholder="请输入名称" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="loadData">查询</el-button> <el-button type="success" @click="openForm()">新增商品</el-button> </el-form-item> </el-form> <el-table :data="tableData" v-loading="loading"> <el-table-column prop="productName" label="商品名称" /> <el-table-column prop="category" label="分类" /> <el-table-column prop="unitMain" label="主单位" /> <el-table-column prop="unitRate" label="换算比例" /> <el-table-column label="操作" width="160"> <template slot-scope="scope"> <el-button size="mini" @click="openForm(scope.row)">编辑</el-button> <el-button size="mini" type="danger" @click="del(scope.row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination :current-page="queryForm.pageNum" :page-size="queryForm.pageSize" :total="total" layout="total, prev, pager, next" @current-change="handlePageChange" /> </div> </template>

进货单页面、销售单页面都可以直接套用这个骨架,不同点只在于筛选字段、表格列和弹窗表单内容。组件化的收益在这里体现得淋漓尽致,页面框架一旦稳定,后续新增模块的效率会成倍提升。

接入采购入库接口时,表单提交的数据结构要和后端DTO对应上。比如进货单表单提交时会同时携带主表字段和明细列表,Axios提交后由后端统一拆解处理。前端这边保证明细数组里的字段名、数量类型和后端实体一致,联调就会非常顺利。

4.4 跨域配置与常用开发代理

联调阶段最容易出问题的就是跨域。我在vue.config.js里配置了开发代理:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }

这样前端开发环境请求/api/purchase/list时,实际上会转发给后端地址的/purchase/list,后端接口不需要额外处理跨域头,开发体验很顺畅。生产部署时再用Nginx统一转发,前后端服务各管各的端口,结构更清晰。

时间日期处理同样是高频坑。后端返回的日期格式经常带T,前端展示时不好看,我统一用Day.js做格式化。Element UI的日期选择器绑定的是Date对象,提交给后端前要格式化成yyyy-MM-dd,否则MyBatis解析日期参数可能报错。建议写一个公共的dateFormat方法,所有提交前统一调用,而不是每个页面各写一套。

5. 运行阶段的典型问题与排错心得

5.1 环境错配问题速查表

真实的开发过程里,环境问题占掉的时间比业务逻辑还多。我整理了一张高频问题速查表:

问题现象排查思路
JDK版本不匹配启动报UnsupportedClassVersionError确认本机JDK与项目编译版本一致
Maven依赖拉取失败控制台大量Could not resolve artifact更换镜像源,检查settings.xml配置
数据库连接失败Communications link failure核对URL、端口、用户名密码,MySQL 8要带时区参数
前端依赖安装失败node-sass安装报错切换国内镜像源,或者锁定Node版本
跨域请求被拦截浏览器提示Access-Control-Allow-Origin配置开发代理或后端加CORS处理

关于数据库连接,MySQL 8的连接URL建议用这种格式:

jdbc:mysql://localhost:3306/fms?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8

不加serverTimezone参数,项目启动时连接数据库直接报时区错误,这是新手最容易卡住的第一道坎。

5.2 库存并发扣减的原子方案

库存超卖是进销存系统里最隐蔽的bug。两个用户同时提交销售单,都查出来库存有100件,都准备扣80件,如果代码是先查库存再更新库存,两个事务同时通过校验,最终库存会被扣成负数。这在农产品交易场景里是绝对不能接受的。

正确方案是把库存扣减做成带条件的原子更新:

int rows = stockMapper.deductStock(productId, quantity); if (rows == 0) { throw new BusinessException("库存不足,无法出库"); }

对应的SQL:

update t_stock set quantity = quantity - #{quantity} where product_id = #{productId} and quantity >= #{quantity}

重点在于where条件里带上quantity >= #{quantity},这样数据库行锁会保证同一时刻只有一个事务能成功更新这一行。如果库存不够,更新的影响行数就是0,程序收到0就抛出业务异常。这个方法比“先select再update”的常规思路可靠得多,因为select和update之间天然存在时间窗口,两个事务同时读到同一个旧值就会出问题。

5.3 金额精度的经典踩坑

有一次对账发现页面上计算的是888元,存到数据库里变成了887.999999,原因显而易见是使用浮点数类型累加导致的精度丢失。进销存系统涉及金额、数量,这些字段一律用BigDecimal,数据库端用decimal类型,不能贪图方便用double。double在二进制浮点运算上会有精度损失,哪怕只是差一分钱,在财务数据上都是大事,这一条直接写进编码规范都不为过。

5.4 中文乱码问题的完整排查链

中文乱码在SSM项目里出现过两种场景。第一种是请求参数乱码,页面提交到后端的关键字全是乱码,解决办法是在web.xml或SpringMVC配置里注册CharacterEncodingFilter强制UTF-8编码。第二种是数据库写入乱码,写入后查出来是问号,这就要看表字段collation是否配置成了utf8mb4,以及JDBC连接URL是否带characterEncoding=utf8。从页面、后端过滤器、数据库连接串、表字段四个环节逐层排查,基本能解决99%的乱码问题。

写在最后的实操体会

这个项目我前后调了三轮。第一轮把功能全部跑通,第二轮发现进销存光有增删改查不算完,补了库存流水、预警、临期提醒,第三轮专门处理了并发扣减和金额精度的问题。做完整套之后最深的体会是:SSM294这类系统表面上是几十个接口加一批页面,但真正决定它能不能落地的,往往是农产品批次、库存并发、金额精度这些平时不太起眼的细节。这些细节处理干净了,整个系统才真正敢拿出去用,答辩的时候也经得住深问。

最后再分享一个自己养成的小习惯:写进销存功能时,不管什么操作,都先在脑子里过一遍“这一笔操作会影响哪几张表”。这个习惯帮我提前消灭了绝大多数逻辑漏洞,在维护这类数据一致性要求高的系统时尤其管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询