接连做了三套进销存相关的项目之后,我得先泼一盆冷水:springboot+vue仓库进销存采购管理系统,听起来像是“一个后台管理页面再加几张表”,但当采购、销售、库存、仓库、供应商、客户这些词凑在一起时,真正难的不是写增删改查,而是如何让业务数据在多个环节流转时不错、不重、不丢。尤其是月末要对账、仓库要盘点的时候,系统里任何一个环节偷懒,最后都会变成Excel表之间对不上的痛苦。
这套项目适合谁?如果你是在校生准备做毕业设计,或者公司内部需要一个能支撑日常业务的小型管理系统,又或者你想用Spring Boot + Vue3把一套真实业务场景完整串一遍,那这篇文章的思路基本可以直接拿来用。我会从业务模块、后端骨架、数据库设计、前端页面、核心流程实现到部署踩坑,把我认为最重要、也最容易忽略的部分都展开讲一遍,不只是给你看代码,更会告诉你每个决定背后的理由。
1. 项目定位与模块设计:先想清楚“进销存”不是一堆CRUD
很多新手拿到这类需求,第一反应就是打开数据库设计工具,直接建商品表、订单表、库存表,然后开始写接口。但这样做十有八九会在开发到一半时发现:采购入库和销售出库都改了库存,月底却对不上账。所以第一步不是建表,而是把业务模块拆清楚。
1.1 主数据和业务单据要分开
进销存系统里存在两类完全不同的东西。一类是长期稳定、很少变动的基础资料,比如商品、供应商、客户、仓库、系统用户,我习惯叫它们主数据;另一类是每天都在发生、一旦发生就要影响库存和财务的凭证,比如采购订单、采购入库单、销售订单、销售出库单、库存盘点单,这些就是业务单据。
主数据通常只做增删改查和启用禁用,操作门槛低。但业务单据一旦提交,往往不能随意删除修改,只能通过“作废”“冲销”“红冲”等方式处理,目的就是保留业务痕迹。这个理念如果不在建表前想清楚,后面代码逻辑会非常别扭。
我在这个系统里把功能模块划分成下面几块:
| 模块 | 核心页面 | 关键作用 |
|---|---|---|
| 基础资料 | 商品管理、供应商管理、客户管理、仓库管理 | 维护所有业务流转的主体信息 |
| 采购管理 | 采购订单、采购入库 | 记录向谁采购、以什么价格采购、实际收到多少 |
| 销售管理 | 销售订单、销售出库 | 记录卖给谁、以什么价格卖、实际发出多少 |
| 库存管理 | 库存查询、库存流水、库存调整 | 实时反映各仓库各商品的可售数量 |
| 报表统计 | 采购汇总、销售汇总、库存台账 | 给老板和对账人员提供月度数据 |
| 系统管理 | 用户、角色、权限 | 控制不同岗位能看到的菜单和操作按钮 |
1.2 角色权限别一开始就做复杂
很多项目死在权限设计上。如果是小公司或毕设项目,我强烈建议先做四种角色:管理员、采购员、销售员、仓管员。权限粒度做到“菜单级+按钮级”就够用了,不需要上规则引擎。
- 管理员:全部菜单和按钮,能看到所有单据和报表。
- 采购员:能管理供应商、采购订单、采购入库,但看不到销售毛利。
- 销售员:能管理客户、销售订单、销售出库,不能随便改采购价格。
- 仓管员:只能看到商品、仓库、库存查询、入库单和出库单的执行,不能创建采购订单。
这种设计既贴合真实业务,又不会让开发周期翻倍。等系统跑起来之后,如果需要更细的数据权限(比如只能看本仓库数据),再在查询条件上增加仓库字段过滤,比一开始就铺开做要稳得多。
2. 技术选型与Spring Boot后端骨架:稳定比新功能更值钱
技术选型没有标准答案,但我要说一句大实话:做业务管理系统,稳定和生态比技术新潮更重要。你不需要在这类项目里硬上微服务、高并发中间件,一个单体应用加一台普通服务器就完全够用了。
2.1 为什么我坚持用 Spring Boot + MyBatis-Plus + MySQL
后端用Spring Boot毋庸置疑,生态成熟、部署简单、招人也好招。ORM层面我首推MyBatis-Plus,原因很直接:进销存系统里有大量条件查询、分页报表和动态SQL,MyBatis-Plus能保持SQL可控,同时自带分页插件和逻辑删除,比JPA在复杂查询上省心得多。JPA的学习曲线和隐性SQL问题,在这种业务场景下容易把人逼疯。
数据库用MySQL就够。字段名尽量统一为下划线风格,比如purchase_order_no、received_quantity,实体里用驼峰命名,MyBatis-Plus的map-underscore-to-camel-case默认开启,省去一堆映射配置。
这里必须提一下版本问题,我踩过springboot版本太高的坑。Spring Boot 3.x确实很新,但它要求JDK 17,并且很多包名从javax.*换成了jakarta.*。如果你用的旧版插件、代码生成器或者第三方依赖没有跟进,启动阶段就会遇到各种类找不到。所以如果是给公司快速交付,我倾向用Spring Boot 2.7.x + JDK 8;如果是新项目并且愿意折腾,用3.x也没问题,但一定要确认所有依赖版本兼容。
2.2 Maven依赖怎么配
如果用Maven,核心依赖大概是这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>如果你用Gradle,其实就是把依赖坐标从xml换成kotlin dsl,核心技术栈不变。对于毕设或者内部项目,Maven的可见性和资料丰富度会更高。
2.3 后端分层的习惯
我习惯把工程分成这五层:
controller:接收参数、做基础校验、返回统一结果。service:写业务逻辑,比如库存加减、状态流转。mapper:只做数据库操作。entity:数据库表对应的实体。dto/vo:接收前端参数的对象,以及返回给前端展示的对象。
这里有个重要原则:Controller里不写库存逻辑,Mapper里不写if-else。所有业务规则都要在Service层完成,这样单元测试好写,出问题也好排查。
封装的统一返回结果一般是这样的:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }再配一个全局异常处理器,把业务异常统一抛给前端提示,而不是让Spring默认返回一大段堆栈信息。否则前端弹窗会非常难看,也容易把内部数据结构暴露出去。
3. 数据库表设计:库存不能乱改,单据必须留痕
表设计是整个系统的地基。我经历过系统上线后库存对不上,最根本原因就是库存表被各种逻辑直接改来改去,没有任何痕迹。所以这一章值得多看几遍。
3.1 核心表拆分成哪些
我建议从基础表、单据表、库存流水表三层来做:
| 表名 | 用途 | 关键字段 |
|---|---|---|
product | 商品主数据 | id, product_no, product_name, spec, unit, price |
supplier | 供应商主数据 | id, supplier_code, supplier_name, contact, phone |
customer | 客户主数据 | id, customer_code, customer_name, contact, phone |
warehouse | 仓库主数据 | id, warehouse_code, warehouse_name, address |
stock | 当前库存快照 | id, product_id, warehouse_id, quantity |
stock_ledger | 库存流水 | id, product_id, warehouse_id, change_type, change_quantity, before_quantity, after_quantity, biz_no, create_time |
purchase_order | 采购主表 | id, order_no, supplier_id, order_status, total_amount, create_time |
purchase_order_item | 采购明细表 | id, order_id, product_id, order_quantity, received_quantity, price |
sales_order | 销售主表 | id, order_no, customer_id, order_status, total_amount, create_time |
sales_order_item | 销售明细表 | id, order_id, product_id, order_quantity, delivered_quantity, price |
sys_user | 系统用户 | id, username, password, role_id, status, warehouse_id |
注意,我特意在采购和销售明细表里加了received_quantity和delivered_quantity,而不是只存订货数量。这是为了支持“一张采购单分两批到货”的真实场景。很多新手直接把订单数量当入库数量用,遇到分批到货就傻了。
3.2 库存表的唯一约束和锁
stock表必须有唯一索引:
ALTER TABLE stock ADD UNIQUE KEY uk_product_warehouse (product_id, warehouse_id);这条唯一约束非常关键。它防止同一个商品在同一个仓库里出现多行库存记录,也让你能用“查不到就插入,插到冲突就更新”的方式简化代码。
库存流水的设计原则是:只能插入,不能修改,不能删除。所有库存变化,无论是采购入库、销售出库、盘点调整还是手工修正,都必须插入一条stock_ledger流水。这样任何时间点都能通过流水重新算出期末库存,出了问题也查得到是哪一笔业务搞坏的。
3.3 单据状态用什么
业务单据的状态不要用0和1这种布尔值,因为进销存的单据状态往往不止两个。采购单我一般用:
DRAFT:草稿PARTIAL_RECEIVED:部分收货RECEIVED:全部收货CANCELLED:作废
销售单类似:
PENDING:待出库PARTIAL_DELIVERED:部分出库DELIVERED:全部出库CANCELLED:作废
用枚举或字符串常量维护,数据库里存英文状态码,前端展示时再翻译成中文。这样以后加状态只改一个地方,比在代码里到处写“=1代表完成”要可靠得多。
4. Vue3 前端落地:路由、请求封装与页面复用的细节
前端部分我用的是Vue3 + Vite + Element Plus + Pinia + Axios。这套组合最大的好处是组件生态成熟,表格、弹窗、表单、分页这些后台管理页面常见元素都有现成方案,开发效率非常高。
4.1 目录结构怎么摆
我习惯把前端工程按业务模块而不是按文件类型组织:
src ├── api │ ├── purchase.js │ ├── sale.js │ └── inventory.js ├── components ├── layout │ └── index.vue ├── router │ └── index.js ├── store │ └── modules ├── utils │ └── request.js └── views ├── dashboard ├── purchase ├── sale └── inventoryapi目录下每个文件对应一个后端模块,里面只写接口请求函数;views按业务页面放文件。这样当你要找“采购入库”相关代码时,直接进views/purchase就能定位。
路由配置用懒加载,避免首屏加载太多无用代码:
{ path: '/purchase/order', name: 'PurchaseOrderList', component: () => import('@/views/purchase/PurchaseOrderList.vue'), meta: { title: '采购订单', permission: 'purchase:order:list' } }4.2 Axios 封装一定要做拦截器
后台管理系统最常见的需求是带Token请求接口、401自动跳登录、接口报错弹提示。所以我强烈建议封装一个request.js:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/store/user' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = 'Bearer ' + userStore.token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response?.status === 401) { const userStore = useUserStore() userStore.logout() router.push('/login') } else { ElMessage.error(error.response?.data?.message || '网络异常') } return Promise.reject(error) } ) export default request这里有几件事必须提前想好:后端返回结构统一成{code, message, data},前端才好在拦截器里统一处理;跨域问题在开发环境用Vite的proxy解决,生产环境用Nginx转发;import.meta.env.VITE_API_BASE_URL要放到.env.development和.env.production文件中,不要把环境地址写死在代码里。
4.3 表格页复用套路
后台页面90%都是“顶部查询表单 + 中间表格 + 底部翻页”的结构。以采购订单列表为例:
<el-form :inline="true" :model="queryParams"> <el-form-item label="订单号"> <el-input v-model="queryParams.orderNo" placeholder="请输入订单号" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form> <el-table :data="tableData" v-loading="loading"> <el-table-column prop="orderNo" label="订单号" /> <el-table-column prop="supplierName" label="供应商" /> <el-table-column prop="orderStatus" label="状态"> <template #default="{ row }"> <el-tag :type="statusTagType(row.orderStatus)">{{ statusText(row.orderStatus) }}</el-tag> </template> </el-table-column> <el-table-column prop="totalAmount" label="总金额" /> <el-table-column label="操作"> <template #default="{ row }"> <el-button link type="primary" @click="handleDetail(row)">查看</el-button> <el-button v-if="row.orderStatus === 'DRAFT'" link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button v-if="row.orderStatus === 'DRAFT'" link type="danger" @click="handleCancel(row)">作废</el-button> </template> </el-table-column> </el-table>这里用到了el-table-column的插槽,也就是Vue3里常说的作用域插槽,它让你能拿到当前行数据,然后在单元格里自定义标签和按钮。很多新手不知道这点,会把状态列直接绑定成英文代码,看起来非常不友好。
4.4 按钮权限怎么控制
简单项目可以用自定义指令控制按钮显示。先定义一个v-permission指令:
app.directive('permission', { mounted(el, binding) { const userStore = useUserStore() const permissions = userStore.permissions if (!permissions.includes(binding.value)) { el.parentNode?.removeChild(el) } } })然后在页面按钮上这样用:
<el-button v-permission="'purchase:order:create'" type="primary">新建采购订单</el-button>这个方案唯一的缺点是页面创建按钮时指令才执行,如果按钮被动态渲染,需要再注意一下。但对付后台管理项目已经足够。
5. 把四条核心业务串起来:从采购到出库,怎么保证库存不丢
这是整个系统最关键的部分。采购入库、销售出库、库存调整、月末盘点,每一条业务线最终都会落到“操作库存表”和“写库存流水表”这两步上。如果这里设计乱了,后面全是坑。
5.1 先画一张状态机图在脑子里
我在实现前会在纸上画出单据状态和库存操作的关系:
- 采购单从“草稿”到“部分收货”到“完成”,每次收货都要增加库存,并写一条类型为“采购入库”的流水。
- 销售单从“待出库”到“部分出库”到“完成”,每次出库都要减少库存,并写一条类型为“销售出库”的流水。
- 仓库盘点或人为修正,走“库存调整”流程,直接写一条调整流水。
状态机的好处是:没有“已完成”的订单不能被再次入库,没有“待出库”的订单不能被重复出库。所有入口都必须先判断状态,再决定能不能继续操作。
5.2 采购入库事务怎么实现
以采购入库为例,核心代码如下:
@Transactional(rollbackFor = Exception.class) public void receivePurchase(Long orderId) { PurchaseOrder order = purchaseOrderMapper.selectById(orderId); if (order == null || !"PARTIAL_RECEIVED".equals(order.getOrderStatus()) && !"DRAFT".equals(order.getOrderStatus())) { throw new BusinessException("当前采购单状态不允许入库"); } List<PurchaseOrderItem> items = purchaseOrderItemMapper.selectByOrderId(orderId); for (PurchaseOrderItem item : items) { Long productId = item.getProductId(); Long warehouseId = order.getWarehouseId(); int quantity = item.getOrderQuantity() - item.getReceivedQuantity(); if (quantity <= 0) { continue; } // 锁住库存行,防止并发重复入库 Stock stock = stockMapper.selectByProductAndWarehouseForUpdate(productId, warehouseId); if (stock == null) { stock = new Stock(); stock.setProductId(productId); stock.setWarehouseId(warehouseId); stock.setQuantity(0); stockMapper.insert(stock); } int beforeQuantity = stock.getQuantity(); stock.setQuantity(beforeQuantity + quantity); stockMapper.updateById(stock); // 写流水 StockLedger ledger = new StockLedger(); ledger.setProductId(productId); ledger.setWarehouseId(warehouseId); ledger.setChangeType("PURCHASE_IN"); ledger.setChangeQuantity(quantity); ledger.setBeforeQuantity(beforeQuantity); ledger.setAfterQuantity(stock.getQuantity()); ledger.setBizNo(order.getOrderNo()); stockLedgerMapper.insert(ledger); // 更新明细已收数量 item.setReceivedQuantity(item.getReceivedQuantity() + quantity); purchaseOrderItemMapper.updateById(item); } // 更新主单状态 if (allItemsReceived(items)) { order.setOrderStatus("RECEIVED"); } else { order.setOrderStatus("PARTIAL_RECEIVED"); } purchaseOrderMapper.updateById(order); }这里有几个点值得解释。selectByProductAndWarehouseForUpdate是数据库行锁,for update会让同一商品、同一仓库的并发操作串行执行,避免两个请求同时读到库存为0然后都插入重复数据。@Transactional保证库存表、流水表、单据表要么同时成功,要么同时回滚,不会出现“库存加了但单据没更新”的脏数据。
5.3 销售出库如何防止超卖
出库不能用“先查再改”的老办法,因为两个销售员同时下单时,可能都查到剩余库存是10,结果都出库8,最后库存变成负数。更稳妥的方式是用条件更新:
UPDATE stock SET quantity = quantity - #{quantity}, update_time = NOW() WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND quantity >= #{quantity}在Service里判断更新行数:
int rows = stockMapper.deductStock(productId, warehouseId, quantity); if (rows == 0) { throw new BusinessException("库存不足"); }这样做的好处是原子操作。数据库在更新这一行时已经加了行锁,根本不需要你手动select for update再update,代码更短也更不容易出错。更新成功后,再插入流水表记录。流水表里的before_quantity需要重新查一次库存,不过只要流水和库存变更放在同一事务里,就不会有太大偏差。
5.4 分批收货和分批出库要怎么做
我在前面表设计里专门加过received_quantity和delivered_quantity字段,就是为了处理分批场景。核心逻辑是:每次入库的数量是本次实际到货数量,而不是订单剩余数量。
判断整张订单是否完成,要看所有明细行的received_quantity之和是否大于等于order_quantity。同理,销售出库时判断delivered_quantity是否达到order_quantity。状态从DRAFT到PARTIAL_RECEIVED到RECEIVED的跳转,也要在事务里重新计算,而不是靠着前端传一个isDone上来。
5.5 月末库存台账怎么出
库存报表不能直接查stock表,因为历史某一时刻的库存已经被后续业务改掉了。更好的做法是基于流水汇总:
SELECT product_id, warehouse_id, SUM(CASE WHEN change_type = 'PURCHASE_IN' THEN change_quantity ELSE 0 END) AS total_in, SUM(CASE WHEN change_type = 'SALE_OUT' THEN change_quantity ELSE 0 END) AS total_out, SUM(change_quantity) AS final_stock FROM stock_ledger WHERE create_time < #{endTime} GROUP BY product_id, warehouse_id如果想算“期初库存加本期入库减本期出库”,就把时间条件拆成两个区间,或者把期初数存到月结表里。总之,流水表是唯一可信的数据源,库存表只是方便日常查询的“快照”。这套思路一定要贯穿到底。
6. 打包部署与那些常见的环境坑
项目写完之后,部署又会碰到一批与环境相关的破事。我把前后端打包和常见的坑列在这里,省得你到了上线那天还在一行行查日志。
6.1 后端打包和启动
后端我用Maven打包:
mvn clean package -DskipTests java -jar target/warehouse-system.jar --spring.profiles.active=prod生产环境的配置我建议全放到application-prod.yml里,不要在启动命令里写一堆--server.port参数。数据库密码、Redis地址等敏感信息在服务器上用环境变量注入,尽量不要提交到Git仓库。
一个容易忽略的点是MySQL连接串。非常常见的问题是时区报错,我一般这么配:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/warehouse_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver6.2 前端构建和Nginx转发
前端先构建:
npm run build生成dist目录,然后配置Nginx。这里有一个特别容易踩的坑:Vue Router如果是history模式,前端刷新除首页之外的页面时会404,必须配置try_files回退到index.html。
server { listen 80; server_name your-domain.com; location / { root /var/www/warehouse-ui; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意proxy_pass后面有没有/api/的区别。如果后端接口统一访问前缀是/api,那么建议后端也设置server.servlet.context-path=/api,让Nginx把带/api的请求完整转发给后端,前端开发环境也用同一个前缀,这样跨环境时最省心。
6.3 部署排查对照表
我把实际开发中碰到最多的几类问题整理成一张表:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
后端无法启动,提示ClassNotFoundException: javax.servlet... | Spring Boot 3.x下依赖还没适配jakarta包 | 检查依赖版本,或退回Spring Boot 2.7.x |
前端npm install报错,找不到@vue/tsconfig | Node版本太老或太新,幽灵依赖 | 锁定Node 18 LTS,删除node_modules重新安装 |
接口报401,前端不跳登录 | axios封装没处理response.status | 在拦截器里统一判断401并清空Token |
| 页面能看到,但接口一直404 | 前后端context-path不一致 | 统一/api前缀,Nginx转发规则匹配 |
| 数据查询中文乱码 | 数据库连接串没加characterEncoding=utf8 | 修改连接串,并确认库表排序规则为utf8mb4 |
6.4 上线后数据库备份
不要等到出了事故才想起备份。我一般用crontab每天凌晨备份一次MySQL:
mysqldump -uroot -p${DB_PASSWORD} warehouse_db > /backup/warehouse_db_$(date +%Y%m%d).sql再配合保留最近7天的压缩包,基本能覆盖绝大多数误删数据的场景。
7. 上线之后最该盯的几件事:对账、日志、权限
系统上线不是结束,反而是一堆新问题的开始。我在这类系统上线后的第一个月,基本只盯三件事:对账、日志、权限调整。
对账这件事,我强烈建议每天都跑一次“当日库存流水汇总”,和订单模块的入库/出库单做核对。如果发现不一致,立刻看流水表,哪一笔业务没有写流水、哪一笔库存变更缺少单据,很快就能定位。等系统跑稳了,再改成每周核对也可以。
日志方面,后端至少把StockLedger这种关键流水表保留三年以上。另外,Spring Boot自带的logback要配置滚动日志,按天生成,保留30天,避免日志文件无限增长。出问题的时候,error级别的堆栈一定会用到。
权限调整则是一个容易被忽视的长期需求。公司员工的角色会换来换去,仓库范围也可能调整。所以用户表里最好带上warehouse_id甚至多个关联仓库,避免以后要给某个人单独开一个仓库权限时还得改代码。
最后再分享一个小经验:这类系统不要急着加太多花哨功能。先把采购、销售、库存三件事做到账实相符,再把报表做准,就已经能帮使用者省下大量Excel时间。很多项目翻车,不是因为技术不行,而是因为一开始业务边界没划清。这套springboot+vue仓库进销存采购管理系统,看着平平无奇,但真正值得花时间研究的,恰恰是那些“不起眼”的库存流水和单据状态。