简介:面向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 '单据号,规则 PO+yyyyMMdd+4位流水', 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:00'3.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true 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.active=dev看到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 R<Void> 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); List<PurOrderItem> 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 id="updateStatusToAudited"> 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' doesn't exist
现象是应用启动到一半就中断,日志里一堆 Quartz 的表名找不到。原因是定时任务用了 JDBC 存储模式,但初始化脚本里没带 Quartz 的建表语句,Spring Boot 也不会替你建。解决有两种:把官方tables_mysql.sql导进库,或者在开发环境先把spring.quartz.jdbc.initialize-schema设成always,让它每次启动自己建(生产环境别这么干,会重复执行)。我现在的习惯是建库脚本必须包含第三方组件的表,不然交接给别人又是一个坑。
坑二:列表页空空如也,数据库里明明有数据
现象是接口返回 200,total是 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 的INCR,key 按业务前缀 + 日期分开,每天自然归零;数据库那层的唯一索引留着当最后一道防线,别因为「不会重复了」就删掉,后悔药就是这么留的。
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 加serverTimezone=Asia/Shanghai,启动参数加-Duser.timezone=Asia/Shanghai,MySQL 服务端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 检查语句,改完就跑一遍,不看页面看数据。玄学问题少了很多。希望帮到你。
本文还有配套的精品资源,点击获取