☰
Java进销存ERP源码跑通指南:从环境配置到库存并发控制
2026/10/7 2:54:24 网站建设 项目流程

简介:这是一套基于SpringBoot开发的Java进销存ERP管理系统源码,面向Java初学者与中小型企业管理软件开发者,提供可直接运行的完整企业级业务解决方案。系统覆盖零售、采购、销售、仓库、财务及报表查询等核心模块,并支持预付款、组装拆卸、仓库调拨、订单管理及细粒度权限控制(精确到按钮级),适用于商贸公司、连锁门店等场景的库存与业务流程数字化实践。资源包共2000个文件,含251个Java业务逻辑文件、260个JS交互脚本、250个CSS与HTML页面、335个编译后class文件及512个PNG图标资源,辅以SQL建表脚本、配置XML与日志配置等,整体压缩包52.83MB,结构清晰、分层明确。目前已有746人学习下载,读者可获得完整前后端代码、多环境部署说明(Windows/Linux)、MySQL5.7适配脚本及AdminLTE+EasyUI双UI界面实现细节,具备良好的教学参考与二次开发基础。

1. 为什么一个“Java进销存ERP管理系统源码”项目,比你想象中更难跑通、更值得深挖?

不是所有标着“Java进销存ERP管理系统源码”的压缩包,解压后都能在你本地IDE里点开就运行。我去年接手过三个自称“完整可运行”的同类项目:一个卡在Oracle JDBC驱动版本不兼容(报错ORA-12505: TNS:listener does not currently know of SID given in connect descriptor),一个因Spring Boot 2.7与MyBatis-Plus 3.5.3的@TableName注解解析逻辑变更导致全表扫描失效,还有一个连登录页都加载不出——排查三天才发现前端静态资源路径被硬编码成/erp-web/dist/,而实际打包输出是/static/。这不是玄学,是真实存在的技术断层。这个标题指向的,不是一个演示Demo,而是一套覆盖采购入库、销售出库、库存调拨、财务对账、多仓库协同的闭环业务系统;它必须能处理单据审核流、批次效期管理、成本结转(如加权平均法)、多币种结算等真实场景。适合两类人:一是想用真实业务系统练手Spring Boot + MyBatis + Vue前后端分离架构的中级Java开发者,二是需要快速验证某家供应商交付物是否具备可维护性的IT运维或甲方技术负责人。它不教Java基础语法,但会暴露你在事务传播、连接池配置、SQL注入防护、并发库存扣减等实战环节的真实水位。


2. 从源码解压到首页渲染:四步走通最小可运行路径

拿到一个名为java-erp-source.zip的压缩包,别急着导入IDE。先做三件事:确认JDK版本锁死在8或11(不是17)、检查数据库类型(90%是MySQL 5.7/8.0或Oracle 11g/12c)、识别前端构建方式(Vue CLI还是纯HTML+jQuery)。下面以最典型的Spring Boot 2.6.x + MySQL 5.7 + Vue 2.6组合为例,走通最小闭环。

2.1 解压与目录结构速判:三秒定位核心模块

解压后,先用命令行快速扫描关键目录:

unzip -l java-erp-source.zip | grep -E "(pom\.xml|application\.yml|src/main|vue|dist|sql)"

你期望看到的结果应包含:

  • pom.xml(根目录)→ 确认Spring Boot版本及依赖坐标
  • src/main/resources/application.yml→ 数据库连接、Redis配置、日志级别
  • src/main/java/com/xxx/erp/→ 主包名,通常含controller、service、mapper、entity子包
  • vue/或frontend/目录 → 前端源码,内含package.json
  • sql/或doc/db/目录 → 必有建库脚本(如erp_init.sql)和初始化数据(init_data.sql)

提示:若pom.xml中<parent>指向spring-boot-starter-parent但版本为2.3.12.RELEASE,而你的IDE默认用JDK 17打开,编译必失败——Spring Boot 2.3.x仅支持JDK 8~15。立刻切换JDK版本,别硬扛。

2.2 后端启动前:数据库初始化与配置修正

假设sql/erp_init.sql内容如下(典型MySQL建库语句):

CREATE DATABASE IF NOT EXISTS erp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE erp_db; -- 表结构省略,重点看这行: CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, status TINYINT DEFAULT 1 );

执行步骤:

  1. 在MySQL中新建数据库erp_db,字符集设为utf8mb4(不是utf8!否则emoji存不进去);
  2. 执行erp_init.sql建表;
  3. 执行init_data.sql插入管理员账号(如admin/123456,密码通常是BCrypt加密后的密文);
  4. 修改application.yml中的数据库配置:
spring: datasource: url: jdbc:mysql://localhost:3306/erp_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: root password: your_mysql_root_password # 注意:不是空字符串,也不是"password" driver-class-name: com.mysql.cj.jdbc.Driver

参数说明:serverTimezone=Asia/Shanghai解决时区错误(常见报错The server time zone value 'XXX' is unrecognized);allowPublicKeyRetrieval=true适配MySQL 8.0+新认证插件;useSSL=false避免证书握手失败——生产环境必须开启SSL并配证书,但本地调试可关。

2.3 前端构建与静态资源接入

进入vue/目录,执行:

npm install npm run build

成功后生成dist/目录。此时关键动作来了:后端必须能正确提供静态资源。检查src/main/resources/application.yml是否有以下配置:

spring: web: resources: static-locations: classpath:/static/,classpath:/public/,file:./dist/

注意最后一项file:./dist/——它让Spring Boot直接读取项目根目录下的dist文件夹(即vue/dist),而非把dist复制进resources/static。若源码中没这行,手动添加,并确保dist目录与pom.xml同级。

2.4 启动与首屏验证:绕过登录直接看首页

直接运行Application.java主类,观察控制台输出:

  • 若见Started Application in X.XXX seconds且无Failed to load bean类错误,后端启动成功;
  • 访问http://localhost:8080,若返回Whitelabel Error Page,说明静态资源未生效——检查dist路径是否拼写错误,或static-locations顺序是否把file:./dist/写在了最后(Spring Boot按顺序查找,classpath:/static/优先级高于file:);
  • 若页面加载但报401 Unauthorized,说明前端路由守卫拦截了未登录请求。临时修改vue/src/router/index.js,注释掉router.beforeEach中的权限校验逻辑,或在localStorage中手动写入token:
// 浏览器控制台执行 localStorage.setItem('token', 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTY5MDAwMDAwMCwiZXhwIjoxNzIwMDAwMDAwfQ.xxx');

此时刷新页面,应看到进销存首页的菜单栏(采购管理、销售管理、库存查询等)。


3. 数据库设计与业务逻辑落地:进销存核心表如何支撑真实流转?

一个能跑起来的系统,不等于能支撑真实业务。进销存ERP的骨架在于四张核心表的设计合理性与关联逻辑——它们决定了系统能否处理“采购入库→销售出库→库存不足预警→财务应付/应收生成”的全链路。我们不讲ER图,只拆真实字段和约束。

3.1 四大核心表:字段设计直指业务痛点

表名关键字段业务含义与避坑点
purchase_order(采购单)order_no VARCHAR(32) NOT NULL UNIQUE,status TINYINT DEFAULT 0 COMMENT '0-草稿,1-已提交,2-已审核,3-已入库',total_amount DECIMAL(12,2)order_no必须全局唯一且带业务前缀(如PO202405001),避免单纯用AUTO_INCREMENT;status用数字枚举而非字符串,提升查询性能;total_amount用DECIMAL而非FLOAT,防止金额计算误差
inventory(库存主表)goods_id BIGINT NOT NULL,warehouse_id BIGINT NOT NULL,quantity DECIMAL(10,2) DEFAULT 0,lock_quantity DECIMAL(10,2) DEFAULT 0goods_id+warehouse_id为联合主键;lock_quantity记录被占用库存(如已下单未出库),扣减库存时需UPDATE inventory SET quantity = quantity - ? WHERE goods_id = ? AND warehouse_id = ? AND quantity >= ?,必须带AND quantity >= ?条件防止超卖
sales_order(销售单)order_no VARCHAR(32) NOT NULL,customer_id BIGINT,delivery_date DATE,payment_term VARCHAR(20) COMMENT '货到付款/月结30天'delivery_date影响库存预留逻辑;payment_term决定应收账款账期,需与财务模块联动生成凭证
finance_voucher(财务凭证)voucher_no VARCHAR(32) NOT NULL,voucher_type TINYINT COMMENT '1-应付,2-应收,3-收入,4-成本',amount DECIMAL(12,2),related_order_id BIGINT COMMENT '关联采购单ID或销售单ID'凭证号需按规则生成(如AP202405001表示应付凭证);related_order_id非外键,但业务逻辑必须保证其存在性,否则对账失败

注意:Oracle用户需将DECIMAL改为NUMBER(12,2),TINYINT改为NUMBER(1),且建表时显式指定NOT NULL——Oracle中VARCHAR2默认允许NULL,易引发空值比较陷阱。

3.2 库存扣减:一个事务里的三重校验

销售出库时,库存扣减绝非简单UPDATE inventory SET quantity = quantity - 10。真实代码需包含:

@Transactional(rollbackFor = Exception.class) public boolean deductInventory(Long goodsId, Long warehouseId, BigDecimal quantity) { // 1. 查询当前库存(含锁量) Inventory inventory = inventoryMapper.selectByGoodsAndWarehouse(goodsId, warehouseId); if (inventory == null) { throw new BusinessException("商品未在该仓库建档"); } BigDecimal available = inventory.getQuantity().subtract(inventory.getLockQuantity()); if (available.compareTo(quantity) < 0) { throw new BusinessException("库存不足,可用:" + available + ",需扣:" + quantity); } // 2. 扣减可用库存(乐观锁防并发) int updated = inventoryMapper.updateQuantity( goodsId, warehouseId, quantity, inventory.getVersion() // version字段用于CAS更新 ); if (updated != 1) { throw new BusinessException("库存扣减失败,请重试"); } // 3. 记录库存流水(inventory_log表) InventoryLog log = new InventoryLog(); log.setGoodsId(goodsId); log.setWarehouseId(warehouseId); log.setChangeType(1); // 1-出库 log.setChangeQuantity(quantity.negate()); // 负数表示减少 inventoryLogMapper.insert(log); return true; }

逻辑说明:version字段实现乐观锁,避免高并发下超卖;inventory_log表必须记录每一笔变动,这是审计追溯的唯一依据;changeQuantity用负数明确标识出库方向,便于后续统计净流入流出。

3.3 成本结转:加权平均法的Java实现

进销存系统必须支持成本核算。加权平均法公式:新单价 = (原库存金额 + 本次入库金额) / (原库存数量 + 本次入库数量)。关键在于每次入库后立即重算该商品在该仓库的平均单价:

public void updateAvgCost(Long goodsId, Long warehouseId, BigDecimal inQuantity, BigDecimal inAmount) { // 查询当前库存信息 Inventory inventory = inventoryMapper.selectByGoodsAndWarehouse(goodsId, warehouseId); if (inventory == null) { // 首次入库,直接设为入库单价 BigDecimal avgCost = inAmount.divide(inQuantity, 6, RoundingMode.HALF_UP); inventoryMapper.insertNewGoods(goodsId, warehouseId, inQuantity, avgCost); return; } // 加权平均计算 BigDecimal oldTotalAmount = inventory.getQuantity().multiply(inventory.getAvgCost()); BigDecimal newTotalAmount = oldTotalAmount.add(inAmount); BigDecimal newTotalQuantity = inventory.getQuantity().add(inQuantity); BigDecimal newAvgCost = newTotalAmount.divide(newTotalQuantity, 6, RoundingMode.HALF_UP); // 更新库存主表 inventory.setAvgCost(newAvgCost); inventory.setQuantity(inventory.getQuantity().add(inQuantity)); inventoryMapper.updateById(inventory); }

参数说明:RoundingMode.HALF_UP是银行家舍入法,符合会计准则;小数位保留6位(非2位),因中间计算需高精度,最终展示时再格式化为2位;inAmount必须是含税价还是不含税价?需与财务制度对齐,源码中若未明确,务必查purchase_order_item表中tax_rate字段参与计算。


4. 避坑指南:那些让开发者熬夜到凌晨三点的典型问题

这类源码项目最大的风险不是功能缺失,而是隐藏在配置、依赖、环境中的“静默故障”。以下是我在五个不同项目中踩过的坑,按现象→原因→解决整理,拒绝模糊描述。

4.1 现象:登录成功后跳转首页,但所有接口返回401,控制台无报错

原因:前端axios拦截器中Authorization头拼写错误,写成了authorzation(少一个i),后端@RequestHeader("Authorization")无法匹配,Spring Security默认拒绝所有请求。
解决:在vue/src/utils/request.js中检查config.headers['Authorization']赋值逻辑,确认拼写;同时在后端WebSecurityConfig.java中开启DEBUG日志:logging.level.org.springframework.security=DEBUG,观察AuthenticationManager是否被调用。

4.2 现象:MySQL 8.0下启动报错java.sql.SQLException: Unknown system variable 'query_cache_size'

原因:源码中pom.xml引用的mysql-connector-java版本过低(如5.1.47),不兼容MySQL 8.0移除的查询缓存变量。
解决:升级JDBC驱动至8.0.33以上,在pom.xml中强制指定:

<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency>

并删除application.yml中所有query_cache_*相关配置。

4.3 现象:Oracle数据库下,SELECT * FROM dual报错ORA-00942: table or view does not exist

原因:MyBatis-Plus 3.4.3+默认使用SELECT 1 FROM DUAL检测数据库连接,但某些Oracle精简版(如XE)未启用DUAL表,或用户权限不足。
解决:在application.yml中添加MyBatis-Plus配置:

mybatis-plus: configuration: database-id: oracle global-config: db-config: id-type: assign_id table-prefix: t_ # 关键:禁用DUAL检测 configuration: default-statement-timeout: 30

并在MybatisPlusConfig.java中注入自定义ISqlInjector,重写getSqlInjector()方法,替换selectOne的SQL为SELECT 1 FROM SYS.DUAL。

4.4 现象:Vue打包后访问http://localhost:8080空白,F12看Network发现/api/login404

原因:后端未配置跨域或反向代理,前端请求发往http://localhost:8080/api/login,但Spring Boot默认只响应/路径,/api/**未映射到Controller。
解决:确认@RestController类上有@RequestMapping("/api"),且Controller方法用@PostMapping("/login");若用Nginx代理,需配置:

location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

4.5 现象:Oracle下INSERT INTO purchase_order (...) VALUES (...)报错ORA-01400: cannot insert NULL into ("ERP"."PURCHASE_ORDER"."ORDER_NO")

原因:order_no字段在Oracle中定义为NOT NULL,但MyBatis-Plus的@TableId(type = IdType.NONE)未触发自动填充,插入时传入null。
解决:在实体类PurchaseOrder.java中添加:

@TableField(fill = FieldFill.INSERT) private String orderNo; // 并在配置类中注册自动填充 @Bean public MetaObjectHandler metaObjectHandler() { return new MyMetaObjectHandler(); }

MyMetaObjectHandler.java中实现insertFill方法,生成PO开头的UUID或时间戳编码。


5. 权限模型与多租户改造:从单机演示到企业级部署的关键跃迁

源码里常见的sys_user、sys_role、sys_menu三张表,只够支撑单个公司使用。当客户提出“我们要给子公司A、B、C分别建独立数据库,但共用同一套后台”,你就得动手改权限模型。这不是锦上添花,而是生存线。

5.1 RBAC模型的现实妥协:菜单权限与数据权限必须分离

标准RBAC(角色-权限-菜单)在进销存中会失效——销售员A能看到自己经手的订单,但不能看采购部的入库单。因此必须叠加数据权限(DataScope):

  • 菜单权限:控制你能看到哪些按钮(如“采购管理”菜单是否显示);
  • 数据权限:控制你能操作哪些数据(如“仅查看本人创建的采购单”、“查看本部门所有销售单”)。

实现方式:在@Select注解的SQL中动态拼接WHERE条件。例如:

@Select("<script>" + "SELECT * FROM purchase_order WHERE 1=1 " + "<if test='dataScope == \"self\"'> AND create_by = #{userId}</if>" + "<if test='dataScope == \"dept\"'> AND dept_id = #{deptId}</if>" + "<if test='dataScope == \"all\"'></if>" + "</script>") List<PurchaseOrder> selectByDataScope(@Param("userId") Long userId, @Param("deptId") Long deptId, @Param("dataScope") String dataScope);

技巧:dataScope值由Shiro或Spring Security的Subject中获取,存储在ThreadLocal中,避免每次查询都查数据库。

5.2 多租户方案选型:共享数据库 vs 独立数据库

方案实现难度扩展性安全隔离适用场景
共享数据库(schema隔离)★★☆中中(依赖SQL过滤)子公司数量<10,数据量<1TB,预算有限
独立数据库(database隔离)★★★★高高(物理隔离)金融、医疗等强合规行业,子公司间零数据互通需求

我们选共享数据库,因其在现有源码上改动最小。核心改造点:

  • 所有表增加tenant_id BIGINT NOT NULL DEFAULT 1字段(1为默认租户);
  • application.yml中配置多数据源,通过AbstractRoutingDataSource动态路由:
public class TenantRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return TenantContext.getTenantId(); // 从ThreadLocal取当前租户ID } }
  • 每次HTTP请求前,通过TenantInterceptor解析请求头X-Tenant-ID,存入TenantContext。

5.3 租户级数据初始化:避免“脏数据污染”

多租户下,init_data.sql不能再直接执行。必须改造为租户感知初始化:

  • 将初始化SQL拆分为base.sql(系统表,如sys_user)和tenant_data.sql(业务表,如purchase_order);
  • base.sql在首次启动时执行一次;
  • tenant_data.sql在每个租户首次登录时,由TenantInitService动态执行,且SQL中所有INSERT语句都带上tenant_id = #{tenantId}参数。

血泪经验:曾有个项目漏了tenant_id字段的索引,导致租户查询SELECT * FROM inventory WHERE tenant_id = ?全表扫描,TPS从200跌到12。上线前务必对所有tenant_id字段加B+树索引。

5.4 日志审计与操作留痕:甲方验收的硬性要求

ERP系统必须满足“谁在何时做了什么”的审计要求。不要用Logback打日志,要落库:

  • 新增sys_oper_log表,字段含user_id、module_name(如“采购管理”)、operation_type(“新增”、“修改”、“删除”)、oper_content(JSON格式记录变更前/后数据)、ip、create_time;
  • 在Service层切面(@Around)捕获所有@Transactional方法,解析参数与返回值,序列化为JSON存入;
  • 关键操作(如库存扣减、财务凭证生成)必须同步写入sys_oper_log,且不允许事务回滚时删除该日志——用TransactionTemplate新开一个不参与当前事务的连接写日志。

我习惯在OperLogAspect.java中加一行log.info("Audit log saved for user: {}", userId);,不是为了监控,而是给运维留个trace ID入口。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询