Spring Boot开源ERP:单据审核与库存一致性实战

发布时间:2026/10/26 18:27:35
Spring Boot开源ERP:单据审核与库存一致性实战 简介面向Java开发者与企业信息化学习者的开源ERP系统源码可作为理解进销存、财务、库存等业务模块组织方式与分层架构的参考材料适合有一定Java Web基础、希望研究中小企业管理系统实现思路的读者。压缩包共1444个文件约2.16MB以1361个Java源文件为主体配套39个XML映射与框架配置、24个HTM页面、13个文本说明及5个properties配置项整体偏向可阅读、可拆解的源代码工程。已有4004人学习或下载说明其在Java ERP参考项目中有一定关注度。读者可从中观察实体建模、业务服务划分、数据库访问、页面交互与参数配置的落地方式并借助重复提交提示等页面线索梳理请求处理与异常反馈流程包体虽小但源码密度高适合按模块检索、对照学习与二次开发调研。1. 三份 Excel 对不上账这套 Java 开源 ERP 补的是哪一段上个月帮一家做五金件的小厂看账最反直觉的一幕是仓库说料在货架上财务说账上没有这批采购说钱早就付了——三份 Excel 摊在桌上谁都不认谁的数。问题不在于谁不认真而是没有一套东西把「单据、库存、往来款」串成一条链每换一个人就断一次。这套开源 Java ERP 就是补这一段。采购、销售、库存、调拨、盘点、往来账这几块是完整的Spring Boot 打底、MySQL 落数据源码是可读可改的那种不是编译好了扔给你的黑匣子。适合两类人一类是想给小微企业搭一套能自己改的内部系统一类是想找个真实业务场景练手单据流转和事务控制的开发者。下面按「它是什么骨架 → 怎么跑起来 → 审核链路怎么走 → 哪些地方会翻车 → 怎么加一张新单据」的顺序拆。2. 技术栈与工程骨架Spring Boot 多模块怎么按业务域切开2.1 选型逻辑ERP 是表单密集型系统不需要花哨先把一件事说清楚ERP 的技术难点不在高并发在「一致性」和「可改」。所有业务动作最后都落成一张单据单据审核之后才动库存、才动钱这决定了架构该往哪个方向走。层次选型在这套系统里承担什么接入层Spring MVC JWT 拦截器登录态解析、接口鉴权、统一返回体包装业务层Spring Boot 多模块采购/销售/库存/财务按业务域拆模块模块间只走 Service 接口持久层MyBatis-Plus MySQL 8主子表单据的增删改查、分页、逻辑删除、自动填充审计字段缓存Redis登录 token、数据字典、单据编号流水号调度Quartz月结任务、库存呆滞预警、单据超期提醒导出Apache POI单据打印、报表 Excel 导出前端Vue Element 系组件库单据列表、编辑表单、打印模板为什么不做微服务我一般会拿一张采购入库单举例它要写单据主表、写明细表、加库存、写库存流水、生成应付凭证五张表必须在一个事务里。拆成微服务之后这个事务要么变成最终一致对账时你会哭要么引入分布式事务框架运维成本翻倍。单体多模块的好处是事务还是本地事务模块边界靠 Maven 依赖管住谁调谁一目了然。Redis 在这里的角色别想复杂了就两个token 存一份方便踢人下线单据编号靠INCR拿流水比max(bill_no)1靠谱得多——后者在并发下必翻车后面避坑那章会细说。2.2 目录结构从启动类到业务模块这套工程的典型结构是多模块 Maven 聚合根 pom 管版本各业务域一个子模块erp-parent/ ├── pom.xml # 依赖版本统一在这里锁子模块不写 version ├── erp-common/ # 工具类、统一返回体 R、业务异常、常量枚举 ├── erp-framework/ # 安全拦截、多租户上下文、AOP 切面、Redis 封装 ├── erp-system/ # 用户、角色、菜单、部门、字典、参数配置 ├── erp-basedata/ # 物料、仓库、供应商、客户、计量单位 ├── erp-purchase/ # 采购订单、采购入库、采购退货 ├── erp-sales/ # 销售订单、销售出库、销售退货 ├── erp-inventory/ # 库存查询、调拨、盘点、库存流水 ├── erp-finance/ # 应收应付、收付款单、凭证 └── erp-admin/ # 启动模块只放 Application 类和配置文件几个位置值得先记住erp-admin/src/main/resources下是application.yml和application-dev.yml跑不起来九成先看这里erp-framework里的拦截器和切面是全系统的横切逻辑改权限、改租户隔离都在这每个业务模块内部都是标准的controller / service / mapper / domain四层找代码不用猜。单据类的模块内部还有一个约定domain下一定有成对的实体比如PurOrder和PurOrderItem主表和明细表分开建类不要在实体里塞List然后指望 MyBatis 自动映射——那个坑踩一次能查一下午。2.3 数据模型主线单据表 明细表 库存表 流水表整套系统真正的骨架就四张表理解了它们其余模块都是换皮。单据表存头和状态明细表存行库存表存当前量流水表存发生过的一切。CREATE TABLE pur_order ( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL COMMENT 组织/租户所有业务表都带, bill_no VARCHAR(32) NOT NULL COMMENT 单据号规则 POyyyyMMdd4位流水, supplier_id BIGINT NOT NULL COMMENT 供应商ID, bill_date DATE NOT NULL COMMENT 单据日期决定入哪个会计期间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已提交 2已审核 3已作废, total_amount DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 价税合计统一4位小数, audit_by BIGINT DEFAULT NULL COMMENT 审核人, audit_time DATETIME DEFAULT NULL, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除, PRIMARY KEY (id), UNIQUE KEY uk_tenant_bill (tenant_id, bill_no) ) COMMENT 采购订单主表; CREATE TABLE inv_stock ( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT 当前库存量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_wh_sku (tenant_id, warehouse_id, sku_id) ) COMMENT 库存余额表一个仓库一个物料一行; CREATE TABLE inv_stock_flow ( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, biz_type VARCHAR(16) NOT NULL COMMENT PUR_IN采购入库 SALE_OUT销售出库 ADJUST调整, biz_id BIGINT NOT NULL COMMENT 来源单据ID方便反查, biz_no VARCHAR(32) NOT NULL COMMENT 来源单据号直接给人看, delta_qty DECIMAL(18,4) NOT NULL COMMENT 正数入库负数出库, PRIMARY KEY (id), KEY idx_biz (biz_type, biz_id) ) COMMENT 库存流水只增不改;几个设计意图值得说透。第一total_amount用DECIMAL(18,4)而不是分成两个整数字段很多老系统那么干四层小数是为了单价乘以数量的中间结果不丢精度只有展示的时候才setScale(2)。第二唯一键把tenant_id放在最前是因为多组织部署时单据号只需要在组织内唯一放后面会让索引选择性变差。第三库存表只存余额绝不存历史——任何「上个月的库存是多少」的问题都从流水表累加算这是唯一不出错的算法。status这个字段是整套系统的命门。草稿可以随便改明细已提交只等审核已审核之后明细一个字都不许动要改只能先作废再重开一张。业务上讲这是留痕技术上讲这是防止「审核后改数量导致库存对不上」这类玄学问题的唯一办法。3. 本地跑通从建库导数据到登录进首页的六个动作3.1 环境准备版本对齐比什么都重要启动失败里至少一半是版本没对齐。这套工程用的是 Spring Boot 2.7 这条线如果拿到的是 3.x 分支注意javax.*全部换成了jakarta.*改包名的时候别只改一半配套 JDK 8 或 17 都行但必须和根 pom 里的maven.compiler.source一致。# 四项基础环境先过一遍版本不对后面全是白折腾 java -version # 建议 JDK 8 或 17与 pom 中 maven.compiler 保持一致 mvn -v # 3.6 以上低版本对多模块聚合支持有坑 mysql --version # 8.0 以上5.7 也能跑但窗口函数相关报表要改 redis-cli ping # 返回 PONG 才算通MySQL 有两个参数建议提前设置好省得后面出玄学问题字符集统一utf8mb4不然物料名称里带了特殊字符会直接插入失败时区统一Asia/Shanghai容器里默认 UTC 的话单据日期会莫名其妙跨月月结的时候你能查到怀疑人生。# my.cnf 里加两行改完重启 MySQL [mysqld] character-set-server utf8mb4 default-time-zone 08:003.2 建库、导初始数据、改三个配置项先把库建出来脚本一般在sql/目录下通常分成erp-schema.sql建表和erp-data.sql初始数据管理员账号、菜单、角色、基础字典。# 建库时就把字符集钉死别指望后面改 mysql -uroot -p -e CREATE DATABASE erp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 先结构后数据顺序反了会报外键找不到 mysql -uroot -p erp sql/erp-schema.sql mysql -uroot -p erp sql/erp-data.sql然后是配置文件。数据源、Redis、单据编号的 key 前缀这三处是必须改的server: port: 8080 spring: datasource: # serverTimezone 不写会差 8 小时allowPublicKeyRetrieval 用于 MySQL 8 默认认证方式 url: jdbc:mysql://127.0.0.1:3306/erp?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: erp password: change_me hikari: maximum-pool-size: 20 # 中小企业规模够用别一上来调 100 connection-timeout: 30000 redis: host: 127.0.0.1 port: 6379 database: 3 # 单独一个库别和别的项目挤 db0 erp: bill-no: redis-key: erp:bill:seq # 单据编号流水Redis INCR 用 security: token-expire: 7200 # token 有效期秒hikari.maximum-pool-size这个值很多人喜欢往大了调觉得连接多就快。实际上一台 4 核 8G 的机器上20 个连接已经能吃满数据库的写入了调太大反而因为连接切换和锁竞争变慢。真遇到连接池耗尽先看是不是有长事务没提交而不是先加连接数。3.3 打包启动与首次登录前后端分离的项目要分别起。后端先打包第一次建议跳过测试测试里可能有依赖外部环境的用例。# 在根目录执行多模块会一起编译第一次会拉一堆依赖慢是正常的 mvn clean package -DskipTests # 启动模块的 jar 一般在 erp-admin/target 下 java -jar erp-admin/target/erp-admin.jar --spring.profiles.activedev看到Started ErpApplication in x.xxx seconds才算真起来。前端一般是npm install npm run dev然后把vue.config.js或.env.development里的代理指向http://127.0.0.1:8080。第一次登录的默认账号通常在初始数据脚本里密码一般是加盐哈希存的别想着去数据库里看明文直接按文档给的默认账号登。提示如果登录接口返回 200 但页面一直跳回登录页八成是前端存的 token 没带上或者后端拦截器把/login也拦了先看浏览器 Network 里请求头有没有Authorization。登录进去之后别急着点业务菜单先去「基础数据」里把物料、仓库、供应商各建一条再去「系统管理」里确认自己的角色有采购模块的权限。这一步跳过了后面审核单据会一直提示无权限你会以为是代码问题。4. 核心链路拆解一张采购入库单从提交到库存变动的完整路径4.1 Controller 层只做三件事别把业务写进来审核接口是最能看出工程素养的地方。合格的 Controller 只干三件事接参数、取登录人、调 Service。业务逻辑一行都不该出现在这里。RestController RequestMapping(/purchase/order) public class PurOrderController { Autowired private PurOrderService purOrderService; /** * 单据审核 * param id 单据主键 * 只做参数接收和登录态获取事务和业务全部下沉到 Service */ PostMapping(/audit/{id}) PreAuthorize(ss.hasPermi(purchase:order:audit)) public RVoid audit(PathVariable Long id) { Long userId SecurityUtils.getUserId(); // 从 JWT 里解析出来的当前登录人 purOrderService.audit(id, userId); return R.ok(); } }PreAuthorize这行别删它对应的是「采购订单审核」这个按钮权限。ERP 里权限颗粒度必须到按钮级不然会出现「能看单据但不能审核」的角色配不出来。SecurityUtils.getUserId()从当前请求上下文取人取不到会直接抛未登录异常这也算一层兜底。4.2 Service 层一个事务里要干完四件事审核这个动作本质上是一次状态迁移附带三个副作用。四件事必须在同一个事务里缺一件数据就歪了。Service public class PurOrderServiceImpl implements PurOrderService { Autowired private PurOrderMapper orderMapper; Autowired private InvStockService stockService; Autowired private FinVoucherService voucherService; Override Transactional(rollbackFor Exception.class) public void audit(Long id, Long userId) { // 1. 带状态条件更新把「防重复审核」交给数据库不靠先查后改 int rows orderMapper.updateStatusToAudited(id, userId); if (rows 0) { throw new BizException(单据不存在或已审核请刷新后重试); } PurOrder order orderMapper.selectById(id); ListPurOrderItem items orderMapper.selectItems(id); if (items.isEmpty()) { throw new BizException(单据没有明细不能审核); } // 2. 逐行入库库存余额 库存流水两件事在 InvStockService 内部完成 for (PurOrderItem item : items) { stockService.increase(item.getWarehouseId(), item.getSkuId(), item.getQty(), item.getPrice(), order.getBillNo()); } // 3. 生成应付凭证借贷不平会在这里抛异常整个事务回滚 voucherService.createPayable(order, items); } }对应的 Mapper 更新语句是关键WHERE status 1就是那道锁!-- 只有「已提交」状态才能被更新成「已审核」两个请求同时进来只有一个能更新到行 -- update idupdateStatusToAudited UPDATE pur_order SET status 2, audit_by #{userId}, audit_time NOW() WHERE id #{id} AND status 1 AND deleted 0 /update这段写法比「先select查状态判断等于 1 再update」强在哪强在原子性。两个用户同时点审核先查后改的写法两边都能查到状态 1然后都去改库存加两遍。带条件更新只有一边rows 1另一边拿到 0 直接抛异常告诉用户刷新干净利落。这算是我踩过的最值钱的一课。4.3 库存层别用「读出来加一加再写回去」库存增减是整套系统里最容易出并发问题的地方。反例长这样select qty from inv_stock where ...Java 里qty delta再update回去。两个人同时操作后写的覆盖先写的库存凭空少一批对账的时候你连是哪天少的都查不出来。正确做法是让数据库自己做加法Service public class InvStockServiceImpl implements InvStockService { Autowired private InvStockMapper stockMapper; Autowired private InvStockFlowMapper flowMapper; Override Transactional(rollbackFor Exception.class) public void increase(Long warehouseId, Long skuId, BigDecimal qty, BigDecimal price, String bizNo) { // 原子累加不给「读-改-写」留窗口行不存在则插入依赖唯一键 int rows stockMapper.addQty(warehouseId, skuId, qty); if (rows 0) { stockMapper.insertIfAbsent(warehouseId, skuId); // 首次入库建余额行 stockMapper.addQty(warehouseId, skuId, qty); } // 流水只增不改任何历史库存都能从这里重算出来 InvStockFlow flow new InvStockFlow(); flow.setWarehouseId(warehouseId); flow.setSkuId(skuId); flow.setBizType(PUR_IN); flow.setBizNo(bizNo); flow.setDeltaQty(qty); flowMapper.insert(flow); } }-- 直接让数据库做加法避免应用层读改写 UPDATE inv_stock SET qty qty #{qty}, version version 1 WHERE tenant_id #{tenantId} AND warehouse_id #{warehouseId} AND sku_id #{skuId}出库就不一样了出库要先校验可用量够不够。这个场景常见做法是用悲观锁SELECT ... FOR UPDATE锁住那一行再判断再扣——因为出库量往往涉及「可用库存 现存量 - 锁定量」的判断纯原子加减没法表达「不够就拒绝」。锁的范围要小只锁单个仓库单个物料那一行别一锁锁一片。4.4 审核人从哪来登录态与数据权限拦截userId不是前端传的是后端从 token 里解出来的这点不能妥协。前端传审核人等于把账做在别人头上一份审计日志就废了。数据权限更隐蔽。常见的实现是一个 MyBatis 拦截器在 SQL 执行前拼上tenant_id 当前租户和「本人/本部门/全部」三种数据范围条件。好处是业务代码完全无感知坏处是调试时看不到真实 SQL——所以我在本地开发时一般会把拦截器拼装后的 SQL 打到 debug 日志里不然查「为什么明明有数据却查不出来」能耗掉半天。5. 避坑记录启动和审核环节最容易翻车的五个现场这一节里的每一条都是真金白银换来的按「现象 → 原因 → 解决」写遇到对号入座就行。5.1 启动与环境类三分钟能定位的别查三小时坑一启动直接报Table erp.qrtz_locks doesnt exist现象是应用启动到一半就中断日志里一堆 Quartz 的表名找不到。原因是定时任务用了 JDBC 存储模式但初始化脚本里没带 Quartz 的建表语句Spring Boot 也不会替你建。解决有两种把官方tables_mysql.sql导进库或者在开发环境先把spring.quartz.jdbc.initialize-schema设成always让它每次启动自己建生产环境别这么干会重复执行。我现在的习惯是建库脚本必须包含第三方组件的表不然交接给别人又是一个坑。坑二列表页空空如也数据库里明明有数据现象是接口返回 200total是 0用客户端工具直连数据库查同样的条件是有的。原因基本都在多租户拦截器上——它自动拼了tenant_id 当前上下文的租户而你登录的账号tenant_id是空的或者初始数据里业务表的租户是 1、用户表里是 0。解决办法是先查sys_user的tenant_id再确认登录成功后有没有把租户写进 ThreadLocal。调试期在拦截器里打一行日志把拼好的 SQL 和租户值都打出来比盲猜快十倍。坑三单据号偶发唯一键冲突现象是保存单据偶尔报Duplicate entry for key uk_tenant_bill重试一下又好了。原因是编号生成用了SELECT MAX(bill_no) 1并发下两个请求拿到同一个值。这个写法在小数据量下几乎不出问题一到月底集中录单就原形毕露。解决是换成 Redis 的INCRkey 按业务前缀 日期分开每天自然归零数据库那层的唯一索引留着当最后一道防线别因为「不会重复了」就删掉后悔药就是这么留的。5.2 业务与数据类这类问题最贵坑四审核之后金额差几分钱财务对不上现象是明细逐行加起来的金额和主表total_amount差 0.01 到 0.05单独查每张单都「看着没问题」。原因多半是中途用了double做汇总或者在明细里先setScale(2)四舍五入再汇总。解决是全链路BigDecimal明细存 4 位小数不做截断主表汇总也用 4 位只有前端展示和打印的时候才setScale(2, RoundingMode.HALF_UP)。改完之后我都要拿一张 3 行明细、单价带 3 位小数的单子验一遍这个用例能抓出九成的精度问题。坑五日期差 8 小时月结跨月现象更隐蔽单据明明是 30 号录的查出来变成 1 号月结的时候这笔算进了下个月。原因有两个叠加一是 JDBC URL 里没写serverTimezone二是容器或 JVM 的默认时区是 UTC。解决要三处一起改URL 加serverTimezoneAsia/Shanghai启动参数加-Duser.timezoneAsia/ShanghaiMySQL 服务端default-time-zone也设成08:00。只改一处往往还是歪的这属于典型的「配了但没配对」。注意作废单据要验证的是「反向操作是否完整」。只把状态改回 3、库存没退、凭证没红冲是最常见的半成品实现。验的时候一定要拿一张已审核的单去作废然后去库存流水和凭证表里找对应的反向记录。6. 二次开发照着加一张「样品领用单」把改造流程走一遍真正的改造场景往往不是改现有模块而是加一张新单据。拿「样品领用单」举例它要出库但不产生应收走一遍就知道该动哪几处。第一步建表直接照抄采购单的主子表结构把supplier_id换成receiver_id领用人状态字段保持一致。第二步生成实体和 Mapper字段名对齐别自创命名。第三步写 Service关键点是库存别自己写 SQL直接调库存模块的出库接口Service public class SampleApplyServiceImpl implements SampleApplyService { Autowired private SampleApplyMapper applyMapper; Autowired private InvStockService stockService; // 复用库存模块别另起炉灶 Override Transactional(rollbackFor Exception.class) public void audit(Long id, Long userId) { // 同样的带状态更新防重复审核 if (applyMapper.updateStatusToAudited(id, userId) 0) { throw new BizException(单据已审核或不存在); } for (SampleApplyItem item : applyMapper.selectItems(id)) { // 出库调用统一入口可用量校验、负数拦截都在库存模块里不用重写 stockService.decrease(item.getWarehouseId(), item.getSkuId(), item.getQty(), SAMPLE_OUT, id); } } }第四步挂菜单和权限sys_menu里加一条目录、一条菜单、两个按钮新增、审核权限标识按sample:apply:audit这种格式写再把这个权限分配给对应角色前端按钮上的v-hasPermi才会生效。这一步漏了功能是好的但没人点得到。验证方法我固定走四步造一张两行明细的单 → 审核 → 查inv_stock数量是否减了对应值 → 查inv_stock_flow有没有两条SAMPLE_OUT记录且biz_id指向这张单。四步都对再拿去给业务试。少验一步等业务那边发现库存少了你连是哪张单造成的都要重新排。改造里最省事的习惯是新单据永远先抄一张结构最像的现成单据改字段、改业务类型、改菜单别从零搭。抄的时候顺手看一眼它的事务注解和状态更新语句那两个地方是整套系统的骨架抄对了后面基本不会出问题。在那之后我每次加新单据都会先把「审核前后库存和流水的变化」写成两个 SQL 检查语句改完就跑一遍不看页面看数据。玄学问题少了很多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询