拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot+Vue ERP管理系统:从单据驱动到账实一致

简介基于SpringBoot与Vue的ERP管理系统源码采用Java8、MySQL5.7后端使用SpringBoot与JPA前端使用Vue和ElementUI实现前后端分离面向Java全栈开发者、毕业设计学生以及需要快速搭建企业信息管理系统的技术人员。功能模块覆盖销售开单、销货历史、进货开单、供应商管理、仓库管理、商品管理、客户管理、用户角色与菜单权限等可完整支撑中小企业基础进销存与权限配置场景。压缩包约14.4MB共438个文件主要包含Java后端逻辑、Vue前端组件、XML配置、JavaScript脚本以及SQL数据库脚本并附带项目管理与Git元数据等辅助内容目录结构完整清晰便于理解项目组织方式和快速启动运行。当前已有6260人学习浏览适合作为课程设计、毕业设计或入门SpringBoot与Vue全栈开发的参考也可基于它进行二次开发以扩展实际业务功能。1. 基于springbootvue的ERP管理系统难的不是框架而是账实一致很多团队把springbootvue的ERP管理系统当成“更复杂的后台管理系统”来做物料、库存、订单各建一张大宽表vue3后台管理系统里用vue-router挂几个页面CRUD跑通就交付。结果月末对账库存数量和财务金额对不上才意识到ERP和进销存根本不是一回事。ERP的核心不是“管理数据”而是“保证账实一致”这背后是单据流转、余额汇总、事务边界、并发控制这几层设计问题。这篇文章按我做企业管理系统的经验从ERP业务模型如何映射到springbootvue的分层架构讲起落到库存事务实现、数据对不上的定位方法最后给出一套月结快照的优化技巧。2. ERP业务模型与SpringBoot分层架构的映射关系2.1 为什么普通的CRUD管理系统不是ERP一个后台管理系统改物料名称直接UPDATE material SET name ? WHERE id ?就结束了。但ERP里改一个物料编码会牵动物料主数据、BOM、库存余额、采购订单、销售订单、财务科目任何一张表没同步月底对账就出问题。所以springbootvue做ERP第一件事是把“单表CRUD”的思维换成“单据驱动”的思维。单据驱动的意思是业务操作不直接改汇总数据而是先生成一张单据比如入库单、领料单、销售出库单单据审核通过后系统再把明细写到流水表同时更新对应的余额表。这个“过账”动作是ERP和普通管理系统的分水岭。我一般会把整个项目分成三块前端vue负责单据录入和展示springboot的Service层负责业务流程和事务边界Mapper层只做数据读写。不要在Controller里写业务逻辑也不要让Service直接改余额表所有余额变动必须经过一个统一的过账服务这样才能保证采购、销售、生产领料都能走到同一条事务链路上。2.2 最小业务闭环采购入库到财务凭证一个ERP系统麻雀虽小五脏俱全但真正能跑通的最小闭环是采购入库 → 生产领料 → 成品入库 → 销售出库 → 财务凭证。这五个环节覆盖了库存、生产、销售、财务四大核心域。业务环节典型单据SpringBoot核心服务Vue对应页面采购入库采购入库单StockInService入库单录入生产领料领料单MaterialIssueService领料单管理成品入库成品入库单FinishGoodsInService入库确认销售出库销售出库单StockOutService出库单管理财务过账记账凭证VoucherService凭证查询这个闭环跑通以后老板问“这个月采购花了多少钱”“仓库还有多少料”你打开vue页面就能答上来。如果某个环节没有单据支撑比如直接改库存余额来“平账”短时间看上去没问题月底一算成本必乱。erp系统业务流程的常见误区就是把财务凭证当成财务部门的独立工作实际上每一张业务单据审核通过后都应该自动生成凭证草稿而不是等财务手动录入等手动录的时候数据早就对不上了。2.3 vue-router动态路由与按钮权限的落地方案前端的第一步不是写页面而是先把路由和权限框架搭好。ERP系统的菜单是根据用户的角色动态变化的仓库管理员看不到财务凭证页面销售专员看不到采购价格。vue-router里最可靠的做法是前端只注册一个空路由表登录后根据后端返回的菜单权限动态添加路由。import router from /router import store from /store // 后端返回的菜单结构比如 [{ path: /stock/in, name: StockIn, component: stock/StockIn }] const menuList store.state.user.menus // 动态注册路由 function registerRoutes(menus) { menus.forEach(menu { // 这里用 vite 的 import.meta.glob 批量加载页面组件 const component loadView(menu.component) router.addRoute({ path: menu.path, name: menu.name, component: component, meta: { title: menu.title, icon: menu.icon } }) }) // 最后挂一个 404 兜底 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }) }这段代码的逻辑是前端router实例先保持干净的初始状态登录完成后从后端拿当前用户的menuList用addRoute逐个注册。loadView负责把组件路径字符串转换成真正的组件拿不到对应组件时直接跳到404页避免用户越权访问。参数说明menu.component是页面组件的字符串路径必须和后端菜单表里的字段一致否则注册失败router.addRoute可以重复调用但会覆盖同名的路由所以退出登录时要清掉动态注册的路由否则切换账号后权限残留。按钮级权限一般用一个自定义指令v-permissionstock:in:audit来控制指令内部检查当前用户的权限点列表没有就移除DOM元素。3. 用SpringBoot实现库存事务与余额一致性3.1 数据模型库存流水与库存余额分开ERP的库存数据必须分成两张表一张流水表记录每一笔出入库明细一张余额表记录当前库存汇总。流水是历史余额是汇总两者可以互相校验。很多新人在建表时只留一张库存表每次出入库都用UPDATE直接改数量这样做数据永远无法追溯。-- 库存流水表每一笔出入库都留痕 CREATE TABLE inv_stock_trans ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_code VARCHAR(32) NOT NULL COMMENT 物料编码, trans_type TINYINT NOT NULL COMMENT 事务类型1入库 2出库 3盘点调整, qty DECIMAL(18,3) NOT NULL COMMENT 变动数量出库为负数, before_qty DECIMAL(18,3) NOT NULL COMMENT 变动前余额, after_qty DECIMAL(18,3) NOT NULL COMMENT 变动后余额, source_bill_no VARCHAR(64) NOT NULL COMMENT 来源单据号, created_by VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_time (material_code, created_at) ) COMMENT 库存流水表; -- 库存余额表当前实时库存 CREATE TABLE inv_stock_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_code VARCHAR(32) NOT NULL, warehouse_code VARCHAR(16) NOT NULL, qty DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 当前可用库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_material_wh (material_code, warehouse_code) ) COMMENT 库存余额表;拆成两张表的核心原因是流水表只追加不修改余额表反复更新。用户在vue页面录入入库单时springboot先插入流水明细状态为“草稿”审核通过后再开启事务同时更新流水状态和余额表。这样就算余额表更新失败流水还在可以重放修复。3.2 入库过账的Service实现与事务边界常见的做法是Service里定义一个stockIn方法整体包在Transactional里先插入流水再更新余额。事务边界就是service方法的边界中间任何一步抛异常整个操作回滚。Service Slf4j public class StockInServiceImpl implements StockInService { Autowired private StockTransMapper transMapper; Autowired private StockBalanceMapper balanceMapper; Override Transactional(rollbackFor Exception.class) public void postStockIn(StockInDTO dto) { // 1. 幂等校验同一张单据不能重复过账 Integer count transMapper.countBySourceBillNo(dto.getSourceBillNo()); if (count 0) { throw new BusinessException(单据 dto.getSourceBillNo() 已过账请勿重复操作); } // 2. 查询当前余额并加锁 StockBalance balance balanceMapper.selectByMaterialAndWarehouse( dto.getMaterialCode(), dto.getWarehouseCode()); if (balance null) { balance new StockBalance(); balance.setMaterialCode(dto.getMaterialCode()); balance.setWarehouseCode(dto.getWarehouseCode()); balance.setQty(BigDecimal.ZERO); balance.setVersion(0); } // 3. 计算变动前后的余额 BigDecimal beforeQty balance.getQty(); BigDecimal afterQty beforeQty.add(dto.getQty()); // 4. 写流水 StockTrans trans new StockTrans(); trans.setMaterialCode(dto.getMaterialCode()); trans.setTransType(dto.getTransType()); trans.setQty(dto.getQty()); trans.setBeforeQty(beforeQty); trans.setAfterQty(afterQty); trans.setSourceBillNo(dto.getSourceBillNo()); trans.setCreatedBy(dto.getCreatedBy()); transMapper.insert(trans); // 5. 更新余额使用乐观锁防止并发覆盖 int rows balanceMapper.updateQtyByVersion( balance.getId(), afterQty, balance.getVersion()); if (rows 0) { throw new BusinessException(库存余额更新冲突请重试); } } }这段代码有几个关键点要说明。countBySourceBillNo的幂等校验放在事务最前面防止前端重复提交或消息队列重复消费beforeQty和afterQty同时写入流水表这样对账的时候不需要回查余额表就能算出某笔单据前后的库存变化updateQtyByVersion带乐观锁条件version不匹配就返回0行抛出异常让整个事务回滚。乐观锁在ERP里几乎是最推荐的并发方案比SELECT ... FOR UPDATE少了锁等待比UPDATE ... SET qty qty - ? WHERE qty ?多了版本判断能明确告诉调用方是谁改坏了数据。3.3 并发扣减库存的丢失更新问题两个仓库管理员同时给同一物料做销售出库一个出10个一个出20个期初库存30个。如果不用任何锁两个事务都读到30分别算出20和10最后后提交的覆盖先提交的库存变成10实际应该扣减后是0账实不一致马上出现。我的经验是库存余额更新统一用条件更新把余额作为更新条件之一。UPDATE inv_stock_balance SET qty qty - #{outQty}, version version 1 WHERE material_code #{materialCode} AND warehouse_code #{warehouseCode} AND qty #{outQty}这里和上面的乐观锁略有区别qty #{outQty}是业务校验库存不足直接更新0行version version 1是并发控制同一时刻只有一个事务能拿到当前version。两条防线一起上再配合Transactional的rollbackFor Exception.class才能保证极端并发下不出现负库存。这里要提一个真实场景出库业务里经常有“预占库存”和“实际扣减”两步。销售下单时锁定可用库存出库时才真正扣减。这个场景就需要在余额表上加两个字段qty_on_hand在库数量和qty_reserved预占数量可用数量是两者之差。预占时更新qty_reserved扣减时同时改两个字段SQL要仔细核对扣减失败还可能要从“预占”回滚。3.4 单据状态机用一张表管住审核流程ERP里每一张单据都有状态草稿、已审核、已过账、已作废。状态不能乱跳比如已作废的单据不能重新审核。我用一张状态流转表来管理而不是在每个Service里写if-else。以入库单为例状态枚举固定为DRAFT(0),AUDITED(1),POSTED(2),VOID(3)。允许的流转路径当前状态允许的下一状态触发动作DRAFTAUDITED, VOID提交审核 / 作废AUDITEDPOSTED, VOID过账 / 关闭POSTED无数据锁定只能红冲VOID无终态在springboot里用一个StateMachine工具类统一校验所有单据的Service先调用stateMachine.canTransit(billType, fromState, toState)不允许就抛异常。这样新增一种单据类型时不需要改每个业务的if-else只需要往状态机里加一张映射表。成本ERP数据没有跑通的大多数原因就是状态机没设计好允许了非法跳跃比如审核之前就过账流水和余额自然对不上。4. 数据对不上与报表连接失败的排查路径4.1 成本数据没有跑通的分层定位思路ERP上线一段时间后出现“成本数据没有跑通”几乎不会是单一原因。按下面这个顺序排查比从头看代码快得多。确认前端vue页面是否真的提交了正确的单据数据payload里的数量和金额是否和纸质单据一致。确认springboot接口有没有返回异常查看日志里的BusinessException和DataIntegrityViolationException前者是业务校验失败后者是数据库约束被打破。确认Transactional是否生效事务是否被try-catch吞掉。确认金额字段有没有用BigDecimaldouble或float在高精度成本计算里是定时炸弹。确认异步任务是否幂等比如定时任务重复执行导致同一笔领料入账两次。这个顺序其实暗合了数据流动的路径页面录入 → 接口入参 → service事务 → 数据库存储 → 汇总统计。4.2 用一条对账SQL定位流水与余额差异当流水表和余额表不一致时最常用的对账方案是按物料汇总流水表的变动数量和余额表的数量做差差异就是出问题的物料。SELECT t.material_code, SUM(t.qty) AS sum_trans_qty, b.qty AS balance_qty, (SUM(t.qty) - b.qty) AS diff_qty FROM inv_stock_trans t LEFT JOIN inv_stock_balance b ON t.material_code b.material_code GROUP BY t.material_code, b.qty HAVING ABS(diff_qty) 0.001;这条SQL以流水表为基准把每个物料的流水变动总量和余额表当前值做比较。diff_qty不为0的物料就是要重点排查的对象多半是某一笔过账事务中途失败流水插入了但余额没更新或者反过来。查到具体物料后再按时间维度缩小范围直接翻那几天的操作日志。4.3 对接外部ERP时报表数据库连接失败的处理实际工作中经常会遇到和其他ERP系统对接的场景。比如财务要求从金蝶、用友或者别的ERP里拉报表数据这时候springboot应用作为调用方去连接对方的数据库或API报“数据库连接失败”非常常见。原因往往不是密码错误而是连接方式不对。可能原因判断方法处理方法对方数据库只允许内网访问在本机telnet测试不通申请跳板机或白名单JDBC URL拼错数据库客户端直连测试核对协议、端口、实例名连接池满导致超时日志显示ConnectionTimeout调大maximum-pool-size驱动版本不兼容报ClassNotFound换成对方数据库匹配的驱动版本我在对接一个SQL Server系的ERP时遇到过这类问题后来检查发现是JDBC URL里少了encrypttrue;trustServerCertificatetrue驱动一直握手失败。连接字符串看起来是对的但细节差一点就连不上。所以遇到连接失败先在数据库客户端里手工连一次再用springboot的DataSource连最后再看是不是被系统的防火墙或安全策略拦了。springboot的数据库连接配置可以显式调大连接池spring: datasource: url: jdbc:sqlserver://192.168.1.10:1433;DatabaseNameerp_db;encrypttrue;trustServerCertificatetrue username: erp_user password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000maximum-pool-size设成20是给ERP这种多并发单据场景的保守值这个参数不是越大越好过大反而挤占数据库连接资源。connection-timeout是获取连接的最大等待时长超过30秒直接失败避免页面无限转圈。DB_PASSWORD用环境变量注入不要把密码明文写在配置里提交到git仓库。4.4 事务回滚被吞掉的经典场景很多事务不回滚是因为代码里把异常吃了。Transactional public void doPost(StockInDTO dto) { try { stockInService.postStockIn(dto); balanceMapper.update(...); } catch (Exception e) { log.error(过账失败, e); // 异常被吞掉事务不会回滚 } }这段代码最大的问题不是catch了异常而是catch之后没有把异常重新抛出去。springboot的Transactional默认只对RuntimeException和Error回滚Exception都不回滚更别说被try-catch吞掉的情况。正确的写法是捕获后抛出一个业务异常或者干脆不捕获让事务边界统一处理。另一个容易踩的坑是事务自调用同类里的一个方法调用另一个带Transactional的方法代理不生效事务就没了。所以事务方法不要放在同一个类的内部调用要么拆成两个Service要么注入自身代理。5. 月度结账后的库存快照与重算工具5.1 快照表解决“历史数据被后来操作污染”ERP跑几个月后最头疼的问题不是功能不够用而是想查“上个月最后一天的库存是多少”。如果直接去余额表看上个月的入库单被作废了、这个月补了一张出库单数值早就变了。我的做法是每月结账时生成一张库存快照表。CREATE TABLE inv_stock_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, month VARCHAR(7) NOT NULL COMMENT 结算月份如2025-05, material_code VARCHAR(32) NOT NULL, warehouse_code VARCHAR(16) NOT NULL, opening_qty DECIMAL(18,3) NOT NULL COMMENT 月初库存, in_qty DECIMAL(18,3) NOT NULL COMMENT 本月入库, out_qty DECIMAL(18,3) NOT NULL COMMENT 本月出库, closing_qty DECIMAL(18,3) NOT NULL COMMENT 月末库存, UNIQUE KEY uk_month_material (month, material_code, warehouse_code) ) COMMENT 库存月结快照;生成快照的SQL就是把当月流水汇总后写入快照表。月末结账时执行一次之后每月的库存报表都从这张表读不回去查流水。这样哪怕后面业务数据被调整历史报表也不受影响。审计的时候也方便直接比较相邻两个月的closing_qty和opening_qty是否衔得上。5.2 提供数据修复能力的重算工具数据已经乱了的时候与其手工在数据库里改来改去不如写一个重算工具。这个工具的输入是一个物料仓库输出是这期间的流水明细和余额变化。核心逻辑就是一个循环先清零余额然后按时间顺序逐条重放该物料的所有流水。-- 重建某个物料的余额 UPDATE inv_stock_balance SET qty 0, version 0 WHERE material_code M001; -- 按时间顺序重放流水 SELECT material_code, qty, created_at FROM inv_stock_trans WHERE material_code M001 ORDER BY created_at, id;拿到流水结果后在springboot里循环累加qty注意出库为负数每算一步都校验累加结果不为负。如果重放过程中某个时间点算出负数说明历史上有超卖或者漏做了入库单这时候不应该强行修正余额而是补一张调整单保留审计痕迹。ERP能用的前提是每一条数据都有来源直接跑SQL改数等于失去了审计能力修完这次下次还会再错。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门