跑完一套商品进销存采购供应管理系统,从需求梳理到上线交付,前后花了两三个月,趁记忆还热乎,把整个设计和实现过程整理出来。项目本身不算大,后台用的是 Spring Boot,前端配 Vue,业务链路却相当完整:采购、入库、销售、出库、供应商管理、库存预警、基础报表全都覆盖。如果你正准备做类似的进销存项目,或者在琢磨“采购供应模块到底怎么跟库存联动”这类问题,这篇应该能帮你省掉不少弯路。
需要先说明一点,这系统不是学校作业,也不是网上那种只有增删改查的 Demo,而是真实拿到业务现场去跑的项目。最开始客户那边的库房管理基本靠 Excel,采购和销售两本账,月底对账能对到半夜。他们要的不只是一套能录单子的软件,而是要一个能实时看库存、能追溯每一笔出入库来源、能自动提醒补货的管理工具。所以整套系统的核心价值落在三个词上:库存准确、单据可溯、流程闭环。
下面按项目推进的顺序拆开讲,内容偏实战,代码和表结构部分可以直接拿去用,但更重要的是理解为什么要这么设计。
1. 业务需求梳理:进销存到底管的是哪几件事
1.1 从单据流转看系统边界
进销存这类系统有个特点,业务用语和开发术语之间天然有一层翻译成本。采购部的人天天说“下了单”“货到了没”,销售那边是“开了票”“出了货”,库房则是“入了库”“发了货”。最早梳理需求时,最忌讳一上来就画表结构,而是先把所有业务动作翻译成一张单据流转图。
这套系统的核心单据一共五类:采购订单、采购入库单、销售订单、销售出库单、库存盘点单。衍生出来还有供应商档案、客户档案、商品档案和分类。你会发现所有业务动作最后都能落到单据上:采购员在系统里建采购订单,货到了库管按订单做入库;销售员先建销售订单,仓库按订单出库;每个月底财务做盘点,拿盘点单去核对账面库存和实物库存。
每次开会我都会强调一个原则:系统里不允许出现没有单据的库存变动。这句话看着简单,实际是整个进销存系统的地基。很多项目做到后期账实不符,都是因为有人图省事,直接去改库存数字。所以从第一版开始,数据库里所有库存变动都必须挂在一个业务单据号下,要么是入库单,要么是出库单,要么是盘点单,查库存的同时能直接反查出变动来源。
1.2 角色权限先想清楚,后面省大事
中小型企业的角色其实没那么复杂,我这边梳理下来就四类:采购员、销售员、库管员、管理员。采购员只管采购订单和供应商,销售员只管销售订单和客户,库管员负责入库、出库、盘点,管理员看全部数据,尤其是报表和库存台账。
权限模型我直接用了很朴素的方式:用户-角色-菜单-按钮。没有引入过于复杂的组织架构和部门隔离,因为按这个体量做过度设计,只会拖慢开发节奏。但有一点值得注意,按钮权限一定要做。实际使用中经常出现的情况是,采购员能打开采购单列表,但不能审核采购单;库管员能入库,但不能删除任何一条入库记录。这些约束在界面上如果不做,后面审计和追责会非常痛苦。
前端处理按钮权限的方式后面单独说,这里先记住结论:角色定粗一点没关系,但菜单和按钮的权限要能配置,这是进销存这类管理系统的通用刚需。
2. Spring Boot + Vue 的组合逻辑与依赖选型
2.1 后端为什么还是选 Spring Boot
市面上可选的方案不少,Python 的 Django、Node.js 的 Express 都能做后端,但放在进销存这类业务管理系统里,Spring Boot 的优势非常实在。第一是生态成熟,权限认证有现成方案,数据库访问层有 MyBatis-Plus 这类工具,做报表可以用聚合查询,基本要什么有什么。第二是事务管理,进销存的入库、出库、扣库存操作对事务一致性要求极高,Spring 的声明式事务在这里特别顺手。
之前也不是没动过换语言的心思,但对比下来发现,团队里 Java 的背景更扎实,而且这套系统后续大概率要接财务接口、对接外部供应链系统,Java 生态在这类政企项目里的交付经验明显更足。选型不一定要追求最时髦,要追求最不容易出意外的方案。
2.2 前端为什么配 Vue 而不是其他方案
前端选 Vue 的理由也很实际。进销存系统是典型的中后台管理系统,页面结构高度重复:左侧菜单、顶部面包屑、中间表格加表单弹窗。Vue 生态里的 Element Plus 组件库和这种场景是绝配,表格、分页、表单、树形控件开箱即用,开发效率比其他方案快很多。
同时 Vue 的数据双向绑定和组件化也适合这种内部管理系统的快速迭代。你在采购单列表调整一个筛选条件,或者在做入库单的时候自动联动商品信息和库存数量,逻辑写起来都很直接。另一个原因是 Vue 的中文社区活跃,遇到问题好搜、好问,交付周期紧的时候这很重要。
2.3 项目骨架与关键依赖清单
后端是用 Spring Boot 2.7 起步的,配合 JDK8,属于保守但稳定的组合。核心依赖不多,列一下:
- spring-boot-starter-web:提供 REST 接口能力
- mybatis-plus-boot-starter:数据库操作,减少手写 SQL 的工作量
- mysql-connector-java:数据库驱动
- spring-boot-starter-security + jjwt:登录认证和安全控制
- lombok:减少实体类样板代码
- spring-boot-starter-validation:参数校验
前端是 Vue3 + Vite + Element Plus,辅助库包括:
- axios:HTTP 请求
- pinia:状态管理
- vue-router:前端路由
- echarts:做报表图表
这套组合跑下来最大的感受是“不折腾”。做进销存系统,稳定压倒一切,框架选熟不选新,这个原则贯穿了全程。
3. 数据模型设计:库存流水是系统的命根子
3.1 六张核心表的职责划分
数据库设计我直接说结论,整套系统最重要的表不是商品表,不是订单表,而是库存流水表。下面这六张是核心:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| product | 商品档案 | 名称、分类、单位、规格、进价、售价、安全库存 |
| supplier | 供应商档案 | 名称、联系人、电话、地址 |
| customer | 客户档案 | 名称、联系人、电话、地址 |
| purchase_order | 采购订单主表 | 单号、供应商、总金额、状态、审核人 |
| purchase_order_item | 采购订单明细表 | 采购单ID、商品、数量、单价 |
| stock_record | 库存流水表 | 商品ID、变动类型、变动数量、变动前后库存、业务单号 |
销售订单和销售出库单的表结构跟采购侧是镜像的,就不单独列了。
商品表里有几个字段要注意。进价和售价必须都在商品表里维护一份默认值,因为做采购入库单的时候,默认带出进价,做销售出库单的时候默认带出售价,这样可以减少手工录入。安全库存字段是后面做低库存预警用的,低于这个数就弹提醒。
3.2 为什么任何库存变动都必须落流水
库存流水表是这个系统里最容易被忽略、但实际最重要的表。业务上每次库存变化,都必须往 stock_record 插入一条记录,内容包含商品、变动类型(采购入库、销售出库、盘点调整、退货入库等)、变动数量、变动前的库存数、变动后的库存数,以及关联的业务单号。
为什么非要上流水?三个实际原因。
第一是对账。月底财务要做进销存汇总,期初库存加本期入库减本期出库应该等于期末库存,没有流水,这个报表就是空中楼阁。第二是追溯。库房如果发现某种商品数量对不上,得能查出来每一笔变动是谁操作的、挂在哪张单子上、单子是谁审核的。没有流水,查都没地方查。第三是反算。进销存报表里的“期间采购”“期间销售”数字,直接可以从流水的类型和时间内聚合出来,不需要去订单表里重新统计。
我见过很多项目把流水表做成可有可无的日志,这是本末倒置。在这里流水不是日志,流水是账本,订单和单据反而是触发账本变动的凭证。
3.3 扣库存的 SQL 与防超卖设计
进销存系统在并发场景下最容易翻车的地方就是扣库存。两个操作员同时出库同一商品,如果逻辑是先查库存够不够,再扣减,那就一定会有超卖风险。实际开发时我把扣减逻辑写成了一个单条 SQL,利用数据库的行锁来保证原子性:
UPDATE product SET stock_num = stock_num - #{num} WHERE id = #{productId} AND stock_num >= #{num}我的做法是,商品表里直接维护了库存字段 stock_num,扣减时用这样一条带条件的 UPDATE,如果返回的影响行数为 0,说明库存不够,业务层直接抛出库存不足异常。这个写法比“先 SELECT 判断再 UPDATE”的天然优势就是:它把判断和扣减合并成了数据库层面的一个原子操作,并发下不会有中间状态。
商品表里还有一个 lock_stock 字段,是预留的锁定库存,用于类似电商下单锁定库存的业务场景。这个项目的采购供应流程比较简单,暂时没有大规模启用,但字段先留着,做二期扩展时不用改表。
4. 采购供应模块:从采购单到入库的完整闭环
4.1 采购单状态机设计
采购供应模块是这套系统的上游,货能不能及时到位、采购价能不能被有效控制,都靠这个模块。采购单的状态我是按真实业务流程拆的,一共六个:待审核、已审核、部分入库、已入库、已完成、已撤销。
创建采购单之后,默认是待审核。采购员可以查看但不能直接入库,必须先由管理员或授权人员审核。审核通过之后,库房里货到了,按采购单做入库操作。入库可能是分批次到货的,比如某种商品先到了一部分,这时候采购单进入部分入库状态,全部到齐并入库后才变成已入库。已完成是财务结算完成后手动标记的状态,已撤销则是审核不通过或采购取消时的落点。
这个状态机的关键点是:入库数量不能超过采购单数量。换句话说,系统强制约束“超收”是不允许的,出现这种情况需要重新走采购流程,避免库管员随手改数字。
4.2 入库操作的事务细节
入库操作是采购模块的核心动作,它不是一个单纯的“给库存加数字”操作,而是一个在同一个事务里完成三件事的组合动作:
- 更新采购单的入库进度,把当前入库数量累加并重新计算状态
- 给商品表增加库存,同样用原子 SQL 实现
- 插入一条库存流水,变动类型是“采购入库”,关联业务单号就是采购单号
这个组合动作必须加@Transactional,否则中间任何一步失败了,都会出现“库存加了但采购单没更新”这种数据不一致的问题。事务看起来是常识,但实际项目里踩坑的地方在于事务失效,后面专门有一节讲这个问题。
代码结构上我建议把“库存变动”抽成一个独立的 Service 方法,采购入库、销售出库、盘点调整都复用它,而不是每一个业务模块各自去写 UPDATE 库存的 SQL。这样所有库存变动的规则都收在一个地方,后续如果增加库存移动、调拨之类的场景,改动成本非常低。
@Transactional(rollbackFor = Exception.class) public void stockIn(Long productId, Integer num, String bizType, String bizNo) { // 1. 扣减/增加库存 int updated = productMapper.increaseStock(productId, num); if (updated == 0) { throw new BizException("商品不存在或库存更新失败"); } // 2. 写入库存流水 stockRecordMapper.insert(buildRecord(productId, num, bizType, bizNo)); }4.3 供应商管理与价格快照
供应商管理本身不复杂,就是档案的增删改查,但有两个细节值得单独提。
第一个细节是供应商虽然可以编辑,但一旦这个供应商名下有采购单,就不能直接删除,只能做停用。否则历史单据上的供应商信息会变成一片空白,追溯链就断了。
第二个细节是采购单价一定要在采购单明细里做快照。商品表里的进价是默认值,但实际采购时价格会波动,月初和月底可能差不少。采购单明细里保存的是当时成交的单价,后面无论是算采购总金额还是做供应商对账,用的都是快照值而不是商品表里的当前值。这个设计跟销售模块同理——销售单里也保存成交售价,不会因为后来改了商品售价而影响历史单据。
5. 销售出库与库存联动:核心链路的实现思路
5.1 销售单与出库单的合体设计
很多进销存系统会把销售订单和销售出库单拆成两个独立单据,先下单后发货。但我做的这套系统里,把两者合成了一个流程:创建销售单时默认带出待出库状态,仓库点一下出库,这张单子就变成已出库,库存同步扣减。
这样设计的原因很实际:客户群体里没有复杂的订单拆分和多次发货需求,基本都是小额批发,一手交钱一手交货。单据拆太细反而让库房多操作一步,他们嫌麻烦。系统设计应该贴合真实场景,而不是套标准 ERP 模板。
销售出库的代码逻辑跟采购入库方向相反,方法名换成了stockOut,整个事务内部也是三步:更新销售单状态、扣减商品库存、写入库存流水。这里扣减用的就是前面提到的那条原子 UPDATE,必须带上stock_num >= #{num}条件。
5.2 库存预警与安全库存设置
库存预警是客户非常在意的功能。他们的痛点是:某些热销商品断货了没人知道,等到客户下单时才发现没货,非常被动。所以在商品表里加了安全库存字段,然后写一个定时任务,每天早上和下午各扫一次。
扫描逻辑很简单,一条 SQL 查出所有商品当前库存小于安全库存的记录,逐个生成预警消息,推送到系统通知栏,并按商品所属的采购员做定向提醒。这里没有引入消息队列和短信推送,因为业务量不需要,系统内通知已经能满足需求。
预警这块还有一个容易被忽略的点:安全库存的数值设定。最初客户完全不设,结果就是一个商品库存为 1 件也触发提醒。后来我把所有商品按近三个月的日均销量跑了一个建议值,低于两周销量的就设为“建议安全库存”,客户再做人工微调,这个功能才真正落地。
6. Vue 前端页面规划与权限控制
6.1 菜单结构与动态路由
前端页面结构是跟着后端权限模型走的。用户登录时,后端返回这个用户有权限的菜单列表和按钮标识,前端根据这份数据动态生成路由和侧边栏菜单,而不是在路由配置文件里写死。
这带来的直接好处是不同角色登录后看到的界面完全不同。采购员看不到销售模块,销售员看不到采购模块,库管员的主页就是出入库操作台。如果只做静态路由,角色多了之后前端要维护一堆状态判断,代码很快会乱。
动态路由的核心实现在路由守卫里,每次页面跳转前,先判断有没有从后端拉取菜单数据,没有就先拉取,再挂载动态路由。还有一个注意点,用户刷新浏览器时动态路由会丢失,喵一眼看去是页面空白,需要在app.vue的初始化逻辑里重新拉取并重新挂载一次。
6.2 Axios 拦截器与登录态管理
前端的登录态通过 Token 维护。Axios 请求拦截器里统一从本地存储取 Token 塞进请求头,响应拦截器里做两件事:统一处理 HTTP 401 状态码,跳转登录页;统一拦截业务异常码,弹出错误提示。这样做的好处是所有模块都不用在业务代码里反复写异常处理。
这里有一个实际踩过的细节问题:业务异常码和 HTTP 状态码要分开。后端返回的 HTTP 状态码始终是 200,但响应体里有一个业务码字段,1 表示成功,其他表示业务错误。如果直接用 HTTP 4xx 表示业务失败,axios 默认会进 error 回调,反而把正常的业务提示(比如“库存不足”)当成了网络错误处理,交互体验很糟糕。
6.3 按钮级权限的自定义指令
菜单权限好做,按钮权限才是容易遗漏的地方。一个采购员虽然能打开采购单列表,但列表上的“审核”按钮他不能点;“删除”按钮只有管理员能看到。按钮级权限我用了一个 Vue 自定义指令来处理。
// 注册指令 app.directive('permission', { mounted(el, binding) { const required = binding.value const userButtons = useUserStore().buttons if (!userButtons.includes(required)) { el.parentNode?.removeChild(el) } } })模板里这样用:
<el-button v-permission="'purchase:audit'" type="primary">审核</el-button>后端接口上同时加了权限校验,前端只是让按钮不显示,真正的安全边界还是要靠后端守住。有些人觉得前端权限只是用户体验,可做可不做,但对于管理类系统,它是审计和追责的基础,不做后面很容易扯皮。
7. 部署上线后的坑与优化记录
7.1 并发入库实测:库存到底有没有多扣
上线前做过一次并发测试,场景是 100 个线程同时对同一个商品做销售出库,初始库存 50 件。如果没有那条原子 SQL,结果大概率是库存被扣成负数。加上stock_num >= #{num}条件后,实际成功的请求只有 50 个,其他 50 个都收到了“库存不足”的业务异常,库存最终稳定在 0。
这个结果验证了一个老经验:库存类操作在数据库层面加条件判断,比在应用层加锁可靠得多。应用层分布式锁虽然也能做,但复杂度高,出问题更难排查。能靠数据库原子性解决的就不要引入中间件。
7.2 事务失效的经典场景:同一个类里方法自调用
开发过程中确实遇到过一次事务没生效的诡异问题。现象是采购入库时库存更新成功了,但插入流水失败,理论上应该整个回滚,结果库存还是被改了。排查到最后发现原因很经典:事务方法被同一个类里的另一个方法直接调用了,没有走 Spring 代理,@Transactional注解完全没生效。
解决方案有两种:一是把库存变动这类方法单独拆到另一个 Service 类里,从外部调用;二是使用动态代理方式调用自身。实践下来还是拆类最直观,顺带也让代码结构更符合单一职责。
7.3 Nginx 部署 Vue 后刷新页面 404
前端部署到 Nginx 时遇到一个经典问题:用户停留在某个页面后按 F5 刷新,出现 404。原因很简单,Vue Router 用了 history 模式,URL 里没有哈希标识,刷新时 Nginx 直接按路径找文件,找不到就 404。
解决方法是 Nginx 配置里加一条 fallback,把所有不存在文件的请求都重定向到 index.html:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }如果前端用了 history 模式,这步是必须的,否则用户多刷新几次页面就以为自己系统坏了。
7.4 报表统计慢:时间范围索引与聚合策略
报表模块刚上线时有个问题:进销存汇总查询在数据量几万条时响应开始变慢。排查发现两个原因:一是库存流水表上的查询条件字段没有联合索引,全表扫描了;二是前端把统计时间范围默认设置得太宽,根本没有范围限制。
优化手段做了两个。第一,在stock_record表上建了(product_id, create_time)的联合索引,聚合查询明显提速。第二,前端限制默认只查当月数据,需要更长区间再手动选择。另外写了一个汇总物化逻辑,每天晚上把前一天的数据聚合到汇总表,跨天查询直接走汇总表,而不是每次都重算全部流水。这样报表部分的性能问题基本解决。
7.5 其他零散但值得记录的注意点
有几件小事也值得提一下。第一个是后端接口的参数校验,建议从一开始就引入@Validated,否则传到数据库的脏数据会让你后期掉头发。第二个是操作日志,商品、供应商、订单的关键操作建议全部记录下来,管理员在界面上能看到谁在什么时间改了什么东西。第三点是数据库连接池和服务器内存要提前给够,进销存系统虽然并发不高,但报表查询和定时任务很吃内存,生产环境我配到了 2G 起步。
最后说一个维护期的经验:进销存系统上线后,真正的维护工作量不在新功能,而在数据修正和权限调整。库房录入错误要冲正,财务要改历史单据状态,员工离职要转移权限。这些都是预期内的运营需求,所以系统设计时一定要留出足够灵活的“作废、冲正、反审核”能力,否则每次修数据都要去动数据库,那才是灾难。
整套系统交付之后,客户那边月底对账从原来的半宿缩短到十几分钟,库存账实误差从每次盘点必改几十行数据,降到基本能对上。能做到这个效果,本质上不是因为代码写得有多花哨,而是流程设计一环扣一环,系统帮业务把每个动作都规范到了流程上。