简介一套基于Java开发的开源ERP系统源码面向希望理解企业资源计划系统整体架构的Java开发者与信息化项目学习者可用于课程设计参考、二次开发练习或技术选型时的对照。压缩包共1444个文件其中Java源文件1361个承载核心业务逻辑与模块实现XML与properties文件用于框架与参数配置HTM页面承担前端交互展示另有少量txt说明和nseer数据文件整体约2.16MB体量不大但目录结构相对完整。目前已有4004人学习下载在同类学习素材中具备一定参考价值。阅读源码时读者可以梳理业务模块的实体建模与分层调用关系观察配置文件如何驱动系统运行借鉴命名规范、接口划分与公共组件的复用方式对需要快速搭建ERP原型或补充设计思路的开发者而言是一份便于按模块检索的对照材料。1. 一个 Java 写的老派 ERP凭什么还能救急小团队的库存账去年帮某公司看账仓库和销售各维护一份 Excel月底对不上差了十七箱货没人认。后来他们换上一套 Java 实现的开源 ERP采购、销售、库存、往来款、权限全在一个后台里单据一审核库存自动扣谁改的、什么时候改的都有流水。这套系统的技术栈不花哨Spring Boot 做后端MyBatis 管 SQLMySQL 存业务数据Redis 放会话和缓存前端是 Vue 加 Element 那一套前后端分离Maven 多模块。它适合三类人十来个人的贸易或批发团队要自建一套账手上已有业务系统、想拿它当 Java 脚手架往里塞模块的开发者以及想找一个真实 CRUD 事务 权限场景练手的人。要是集团型多组织、强生产排程的制造场景它的深度不够别硬凑。2. 技术栈与分层架构一张销售出库单背后的四层目录2.1 选型清单每一层为什么是它开源 ERP 这类系统最怕两件事一是业务逻辑散在 SQL 里改一个字段要翻十个文件二是事务边界画不清审核一半失败留下半张单。这套系统的选型基本就是围着这两个问题转的。层次典型选型在 ERP 里承担的职责接入层Spring MVC 统一响应包装参数校验、登录态解析、异常转错误码业务层Spring Service Transactional单据审核、库存扣减、金额重算持久层MyBatis / MyBatis-Plus XML复杂报表 SQL、动态条件查询存储层MySQL Redis业务数据、热点字典、登录 token前端Vue Element axios 封装单据表格、审批面板、权限按钮控制为什么持久层是 MyBatis 而不是 JPAERP 的报表查询条件非常脏客户、商品、仓库、时间段、状态可以任意组合还要按金额区间过滤。这种 SQL 用 XML 手写反而清楚字段名和索引能一眼对上。MyBatis-Plus 只在单表增删改上省事真正的库存扣减、往来账汇总这类语句我一般还是老老实实写 XML。Redis 在这类项目里通常不是缓存主力主要是登录态、验证码、字典缓存、单号当天序号的计数器。这一点很重要如果你以为它扛了大部分查询排查慢请求时会找错方向。2.2 目录结构Maven 多模块的依赖是单向的erp-parent ├── erp-common 工具类、统一返回 R、异常、常量、枚举 ├── erp-model 实体、DTO、查询参数无业务逻辑 ├── erp-dao Mapper 接口 resources/mapper/*.xml ├── erp-service 业务实现事务在这一层 ├── erp-web Controller、拦截器、启动类、配置文件 └── erp-ui 前端工程独立 npm 构建依赖方向是web → service → dao → modelcommon被所有模块依赖。这个方向不能反过来一旦 service 里 import 了 web 层的类打包时必然循环依赖mvn install会卡在某一模块报Could not resolve dependencies。erp-model里要区分两类对象和数据库表一一对应的实体以及给前端用的 DTO。血泪经验是把两者合成一个类前期省事后期加一个「明细行小计」的展示字段就得在实体上打TableField(exist false)越堆越多最后没人敢动。2.3 一次「销售出库单」保存请求走过的链路先看入口。Controller 只做三件事接参数、校验、调服务。业务判断一律不写在 Controller 里。RestController RequestMapping(/api/sale/out) public class SaleOutController { Autowired private SaleOutService saleOutService; /** * 保存并审核一张销售出库单 * param form 表头 明细的整体表单明细以数组形式挂在 items 上 */ PostMapping(/saveAndAudit) public RLong saveAndAudit(RequestBody Valid SaleOutForm form) { // 单号、状态、金额都在 service 里生成Controller 不碰 Long billId saleOutService.saveAndAudit(form); return R.ok(billId); } }R是统一响应体code / msg / data三段。Valid配合 DTO 上的NotNull把空客户、空仓库这类错误挡在业务层之前。真正干活的是 Service事务注解加在这里且必须写rollbackFor Exception.class否则受检异常不回滚单据会只落一半。Service public class SaleOutServiceImpl implements SaleOutService { Autowired private SaleOutHeadMapper headMapper; Autowired private SaleOutItemMapper itemMapper; Autowired private StockService stockService; Autowired private BillNoGenerator billNoGenerator; Override Transactional(rollbackFor Exception.class) public Long saveAndAudit(SaleOutForm form) { // 1. 表头落库单号规则前缀 yyyyMMdd 当日四位序号 SaleOutHead head form.toHead(); head.setBillNo(billNoGenerator.next(XSCK)); head.setStatus(BillStatus.WAIT_AUDIT.getCode()); head.setTotalAmount(BigDecimal.ZERO); headMapper.insert(head); // 2. 明细批量落库行号由前端顺序给定不依赖数据库自增 ListSaleOutItem items form.toItems(head.getId()); itemMapper.insertBatch(items); // 3. 金额在服务端重算后回写表头绝不信任前端传来的 total BigDecimal total items.stream() .map(i - i.getQty().multiply(i.getPrice()).setScale(4, RoundingMode.HALF_UP)) .reduce(BigDecimal.ZERO, BigDecimal::add); headMapper.updateTotal(head.getId(), total); // 4. 审核动作扣库存 写流水任一行失败整单回滚 stockService.deduct(head.getWarehouseId(), items, head.getBillNo()); return head.getId(); } }参数说明form里的customerId、warehouseId必填items至少一行qty用BigDecimal而不是Integer因为很多商品按重量或半箱出库。billNoGenerator内部一般用 Redis 的INCR加过期时间做到「按天归零」key 形如billno:XSCK:20240501过期设 25 小时。如果直接用数据库MAX(bill_no)1两个人同时点保存就会撞唯一键。3. 本地跑通从建库到登录后台的七个动作3.1 先把版本对齐别急着 install拿到源码先别敲mvn spring-boot:run。打开根pom.xml看三件事spring-boot.version、maven.compiler.source、是否有profiles区分 dev/prod。JDK 版本对不上报错信息往往指向某个完全无关的类很容易被带偏。java -version # 8 或 11与根 pom 的 compiler 配置保持一致 mvn -v # 3.6 mysql --version # 5.7 / 8.0 都行8.0 注意驱动与时区参数 redis-cli ping # 返回 PONG 再往下走我第一次跑这类项目就翻车在这里本机 JDK 17项目是 JDK 8 写的mvn install报invalid target release: 1.8以为是 pom 写错了其实是JAVA_HOME指到了新版本。改环境变量比改 pom 安全得多。3.2 建库并导入初始化脚本初始化脚本一般在erp-web/src/main/resources/sql/或项目根的doc/下里面分两类建表语句和基础数据菜单、角色、默认管理员、计量单位、仓库。顺序不能颠倒菜单表有外键指向角色表。-- 字符集统一 utf8mb4否则商品名里的特殊字符会变成问号 CREATE DATABASE erp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER erp% IDENTIFIED BY 换成你的密码; GRANT ALL PRIVILEGES ON erp.* TO erp%; FLUSH PRIVILEGES;# 先结构后数据两条命令分开跑出错容易定位 mysql -uerp -p erp sql/schema.sql mysql -uerp -p erp sql/init-data.sql导入完先SELECT COUNT(*) FROM sys_user;确认基底数据进去了。默认管理员账号一般在init-data.sql里明文写着第一次登录后立刻改密码。3.3 改配置重点是时区和时区相关的连接参数配置文件通常是application.yml加application-dev.yml。生产密码别写死在 git 里用环境变量占位。server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/erp?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse username: ${DB_USER:erp} password: ${DB_PWD:你的密码} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml # 路径少一层星号就扫不到子目录 configuration: map-underscore-to-camel-case: trueserverTimezone不写MySQL 8 下时间字段会整体偏 8 小时单据日期看着没错、流水时间全乱这类问题最容易被当成业务 bug 查一整天。3.4 启动后端并验证接口层# -am 会连带构建依赖模块第一次必须加否则报找不到 erp-common mvn clean install -DskipTests -pl erp-web -am java -jar erp-web/target/erp-web.jar --spring.profiles.activedev看到Started ErpApplication in x seconds才算启动成功。注意日志里有没有Mapped {[/api/...]}没有说明 Controller 没被扫到检查启动类的ComponentScan包路径。先别开前端用 curl 打一个不需要登录的接口验通curl -i http://127.0.0.1:8080/api/common/captcha返回 200 和图片流就说明 MVC 通了返回 404 是路径问题返回 500 一般是数据源没连上去日志里找Communications link failure。3.5 前端联调与代理配置cd erp-ui npm install # 锁文件存在就用 npm ci快且可复现 npm run dev # 默认 3000 或 8081看 package.json 的 scripts开发环境靠代理绕开跨域关键是别把/api前缀 rewrite 掉——后端接口路径本身就带/api多此一举会得到一片 404。// vue.config.js 或 vite.config.js export default { server: { proxy: { /api: { target: http://127.0.0.1:8080, // 与后端 server.port 对齐 changeOrigin: true // 不要写 pathRewrite: { ^/api: }后端路径里有 /api } } } }登录后如果左侧菜单是空的八成是角色没绑菜单或者菜单表里的component路径和前端views目录对不上这两个地方逐个核。3.6 打包部署时最容易忽略的一步前端构建产物要放进后端的静态目录或者用 Nginx 单独托管。前者简单把dist内容拷到erp-web/src/main/resources/static/再打 jar后者更常见Nginx 里location /api反代后端其余try_files回落到index.html否则刷新页面就 404。4. 核心模块拆解单据、库存、权限三块最容易出事的地方4.1 单据模型表头加明细是这套系统的骨架所有业务单据——采购入库、销售出库、调拨、盘点——都遵循同一个模式一张表头存总量和状态一张明细存行项目用head_id关联。学透一组其余都是复制。CREATE TABLE erp_sale_out_head ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL COMMENT 业务单号全局唯一, customer_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, total_amount DECIMAL(18,4) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1待审 2已审 3作废, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_bill_no (bill_no), KEY idx_customer_time (customer_id, create_time) ) COMMENT 销售出库单表头; CREATE TABLE erp_sale_out_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, head_id BIGINT NOT NULL, row_no INT NOT NULL COMMENT 行号界面排序与打印模板用, goods_id BIGINT NOT NULL, qty DECIMAL(18,4) NOT NULL, price DECIMAL(18,4) NOT NULL, amount DECIMAL(18,4) NOT NULL, KEY idx_head (head_id), KEY idx_goods (goods_id) ) COMMENT 销售出库单明细;row_no这个字段很多人省掉用自增 id 排序结果打印模板和界面顺序不一致客户对账时找不到行。status用数字而不存中文是为了后续加审批流时不用改数据。明细永远不存「合计」合计由明细汇总或者审核时回写表头并在明细变动时重算。两处都存又不同步是这类系统最常见的对账差异来源。4.2 库存扣减三个方案和一个必答问题必答问题是两个人同时卖同一批货谁的库存判断生效答案只能靠数据库的原子性不能靠 Java 里先select再if再update。常见三种做法。第一种是SELECT ... FOR UPDATE悲观锁简单但把行锁住出库高峰会排队。第二种是乐观锁版本号冲突时抛异常让前端重试适合并发不极端的贸易场景。第三种是只写流水不维护结存查询时汇总数据最准但报表慢。这套系统走的是第二条路。!-- StockMapper.xml影响行数为 0 就代表扣减失败 -- update iddeduct UPDATE erp_stock SET qty qty - #{qty}, version version 1 WHERE goods_id #{goodsId} AND warehouse_id #{warehouseId} AND version #{version} AND qty #{qty} /update参数说明#{qty}是本次出库数量#{version}是查询时读到的版本号qty #{qty}这个条件不能省——它是最后一道防线库存不足时直接更新 0 行比在 Java 里判断更可靠。public void deduct(Long warehouseId, ListSaleOutItem items, String billNo) { for (SaleOutItem item : items) { Stock stock stockMapper.selectByGoods(warehouseId, item.getGoodsId()); if (stock null) { throw new BizException(商品[ item.getGoodsId() ]在该仓库没有库存记录); } int rows stockMapper.deduct(item.getGoodsId(), warehouseId, item.getQty(), stock.getVersion()); if (rows 0) { // 并发被抢占或库存不足整单回滚前端提示重试 throw new BizException(库存不足或已被他人修改请刷新后重试 item.getGoodsId()); } // 写流水唯一键 (bill_no, goods_id, warehouse_id) 保证重复审核不重复扣 stockJournalMapper.insertIgnore(billNo, item.getGoodsId(), warehouseId, item.getQty()); } }配合流水的唯一键就拿到了幂等同一张单被重复审核第二次的流水插入被忽略库存也不会再扣一次。这条后悔药我是每个项目都提前埋的。4.3 权限功能权限和数据权限是两件事菜单和按钮级别的控制靠角色菜单表登录后把权限码塞进 token前端用指令控制按钮显隐。但真正难的是数据权限——销售员只能看自己跟的客户仓管只能看自己管的仓库。常见做法是用注解加拦截器在 SQL 后面拼条件而不是在每个 Service 里手写if。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { /** 隔离字段creator 按创建人warehouse_id 按仓库 */ String column() default creator; /** 是否允许管理员绕过 */ boolean ignoreAdmin() default true; }// 拦截器里把当前用户可见范围塞进 ThreadLocalMapper XML 用 if 动态拼接 Long userId UserContext.getUserId(); boolean isAdmin UserContext.hasRole(admin); if (!(isAdmin scope.ignoreAdmin())) { DataScopeHolder.set(scope.column(), userId); }Mapper 里对应写AND ${scopeColumn} #{scopeValue}注意这里用${}是因为列名不能参数化所以列名必须来自注解常量白名单绝不能让前端传列名进来否则就是注入。数据权限上线前必须做一件事用一个非管理员账号逐页翻一遍列表确认看不到别人的单据。我在某项目上就见过注解加了但拦截器没注册测试环境全是管理员账号上线第一天数据全裸奔。4.4 金额与精度前端一句话后端查三天金额一律用BigDecimal数据库用DECIMAL(18,4)接口用字符串传。三条缺一不可。public BigDecimal calcAmount(BigDecimal qty, BigDecimal price) { // 先乘再统一四舍五入到 4 位不要中途 setScale误差会累积 return qty.multiply(price).setScale(4, RoundingMode.HALF_UP); }前端如果直接用Number处理金额0.1 0.2会得到0.30000000000000004传到后端再存进去对账时差几分钱查起来非常痛苦。所以接口层统一把金额序列化成字符串前端只做展示任何参与计算的合计都在服务端重算。5. 避坑与排查启动报错、库存成负数、金额差一分5.1 启动就报找不到数据源现象Failed to configure a DataSource: url attribute is not specified。 原因application.yml没被加载或者 profile 选错spring.profiles.active指向的文件不存在时Spring 不会报错只会用空配置启动。 解决启动参数显式指定--spring.profiles.activedev并在日志开头确认The following profiles are active: dev。多模块项目还要检查配置文件是否真的在erp-web/src/main/resources下放在子模块里是扫不到的。5.2 库存扣成了负数现象某商品库存显示 -3且没有任何报错。 原因更新语句里漏了AND qty #{qty}或者库存表初始记录不存在UPDATE影响 0 行但代码没检查返回值。 解决把「影响行数为 0 就抛异常」当成硬性规范并在库存表上加CHECK (qty 0)MySQL 8 支持让数据库兜底。发现负数后先做数据修复UPDATE erp_stock SET qty (SELECT IFNULL(SUM(qty),0) FROM erp_stock_journal WHERE ...)用流水重算比手工改安全。5.3 单号重复保存直接失败现象Duplicate entry XSCK202405010003 for key uk_bill_no。 原因单号生成用了「查当天最大号 1」并发下两个请求读到同一个最大值。 解决改用 RedisINCR的原子自增key 带日期并设 25 小时过期或者建一张sys_sequence表用UPDATE ... SET val val 1再回查。单号里不要塞随机数出问题排查时无法从单号读出业务日期。5.4 时间整体偏 8 小时现象新建单据的创建时间是凌晨实际是下午。 原因JDBC URL 缺serverTimezone或 MySQL 服务端time_zone是 UTCTIMESTAMP字段被来回转换两次。 解决连接串写serverTimezoneAsia/Shanghai统一用DATETIME而不是TIMESTAMP存业务时间应用层时间取LocalDateTime.now()。上线前跑一条SELECT NOW();和后端打印的时间对比一下两分钟的事能省一天。5.5 列表页越翻越慢现象单据列表前几页正常翻到第 200 页要等十几秒。 原因LIMIT 100000, 20这种深分页MySQL 要扫掉前十万行同时关联了客户表、商品表、用户表EXPLAIN里全是ALL。 解决业务上限制最大页数或强制加时间范围SQL 上先按条件在单表筛出主键再回表关联给(customer_id, create_time)、(status, create_time)建联合索引。改完务必用EXPLAIN确认type从ALL变成range别凭感觉。6. 二次开发给自定义单据加字段、加审批的改造路径真实需求几乎都是这样开头的出库单要加一个「项目号」还要按金额走两级审批。别急着动表先想清楚改哪几处。加字段的最小闭环是五处数据库加列、实体加属性、Mapper XML 的insert和select补字段、前端表单加输入框、列表加一列。少任何一处症状都不一样——少 Mapper 是保存成功但查出来是空少前端列表是数据在库里但界面看不见最容易误判成「后端没存上」。改动点文件位置常见失误加列sql/alter_*.sql没给默认值历史数据变 NULL实体erp-model的 Head 类用了包装类却忘了 getter持久层mapper/SaleOutHeadMapper.xmlif test里多写了字段名表单erp-ui/src/views/sale/out/form.vuev-model绑错层级列表同目录list.vue列宽挤成一团如果这个字段只在个别客户那里用我一般不改主表而是加一张erp_bill_ext扩展表bill_id field_code field_value三列代价是查询要 join好处是主流程零改动第三方定制不污染主干。审批流不要自己造轮子。这类系统通常已有status字段和审核按钮最小改造是把「审核」拆成「提交」和「终审」两个动作中间插一张erp_approval_record记单据类型、单据 id、节点、审批人、意见、时间。金额分级判断写在 Service 里超过阈值就先落到「待二级审批」而不是改状态机。改完必须验证三件事造一张正常单据走完整流程看库存流水和结存是否对得上人为在审核时抛异常确认整单回滚、没有孤儿明细用低权限账号登录确认新加的字段和按钮该隐藏的隐藏。我吃过一次亏新加的「成本价」字段忘了加权限控制仓管账号在列表里直接看到了进价虽然最后没出事但那之后我每次加字段都会先问一句「这个字段谁能看」。上线前再补一步把新加的 SQL 变更单独放进sql/upgrade/目录并按日期命名别直接改schema.sql。线上库是改出来的不是建出来的这条规矩能救命。希望帮到你。本文还有配套的精品资源点击获取