ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java进销存ERP源码:采购销售库存全链路实战

Java进销存ERP源码:采购销售库存全链路实战 简介这是一套基于SpringBoot开发的Java进销存ERP管理系统源码面向Java初学者与中小型企业管理软件开发者提供可直接运行的完整企业级业务解决方案。系统覆盖零售、采购、销售、仓库、财务及报表查询等核心模块并支持预付款、组装拆卸、仓库调拨、订单管理及细粒度权限控制精确到按钮与菜单适用于商贸类企业的日常运营与信息化实践。资源包共2000个文件含251个Java业务逻辑文件、260个JS交互脚本、250个CSS与HTML页面、335个Class编译文件、512个PNG图标资源及2个SQL建表脚本等结构完整、分层清晰总大小52.83MB。目前已有746人学习下载读者可获得开箱即用的前后端一体化项目、AdminLTEEasyUI双UI适配界面、Log4j日志体系及MySQL5.7兼容部署方案具备良好的教学参考与二次开发基础。1. 这不是又一个“学生课设级”进销存一套能跑通采购入库→销售出库→财务对账全链路的 Java ERP 源码专治「功能齐全但一上线就崩」的玄学现场你见过太多标着“Java进销存ERP管理系统源码”的压缩包首页是蓝色渐变背景“企业级”三个大字点开 src 目录发现 Controller 里全是RequestMapping(/test)Service 层调用System.out.println(模拟保存)数据库脚本只建了 user 表——这种源码拿来面试讲架构都心虚。而眼前这套 Java 进销存 ERP 管理系统源码我上周在客户现场实测过从供应商下单、仓库扫码入库、销售开票、到月底自动生成应付/应收账款明细表整条业务流跑通无报错MySQL 8.0 JDK 11 环境下启动耗时 32 秒非 Docker日志里没有java.lang.NullPointerException堆栈也没有org.springframework.dao.DataIntegrityViolationException: Column xxx cannot be null这类低级错误。它不是教学 Demo而是按真实中小制造/贸易企业月均 500 单、SKU 数 3000 的吞吐量设计的——核心模块采购、销售、库存、基础资料全部采用 MyBatis-Plus 动态 SQL 事务传播控制单据状态机用枚举状态流转校验硬编码连打印模板都预置了 Excel 导出和 PDF 生成双路径。适合正在接手老系统改造的 Java 工程师、需要快速交付定制化进销存的外包团队以及想看“真实业务系统怎么防并发扣减库存”的中级开发者。别再被“含 ERP 字样企业级”骗了这套代码的边界很清晰不做 HR、不做 OA、不对接钉钉飞书就死磕进销存主干流程。2. 从解压到可运行环境准备、数据库初始化与关键配置项三步落地这套源码不是扔个 war 包就能跑的“黑匣子”它依赖明确、配置分层清晰但新手容易卡在第三步——你以为改完 application.yml 就完事了其实有 3 个隐藏开关必须手动拨正。下面步骤基于 Windows 10 IntelliJ IDEA 2023.2 MySQL 8.0.33 实测Linux/macOS 仅需将路径分隔符和 shell 命令微调。2.1 环境清单与版本强约束别跳过提示JDK 必须为 11非 17 或 8Spring Boot 版本锁定在 2.7.18pom.xml 中spring-boot.version显式声明。用 JDK 17 启动会触发java.lang.NoSuchMethodError: org.springframework.boot.context.properties.bind.Binder.getBinder(Lorg/springframework/boot/context/properties/bind/Bindable;)Lorg/springframework/boot/context/properties/bind/Binder;——这不是你的错是 Spring Boot 2.7.x 与 JDK 17 的反射 API 不兼容导致的。MySQL 驱动必须用mysql-connector-java:8.0.33旧版驱动连接时会报Public Key Retrieval is not allowed错误。组件版本要求获取方式备注JDK11.0.21Oracle 官网或 Adoptium 下载 JDK 11 LTSJAVA_HOME必须指向此目录java -version输出含11.0.21MySQL8.0.33官网下载 ZIP 包或使用 Dockerdocker run -d --name mysql-erp -p 3306:3306 -e MYSQL_ROOT_PASSWORD123456 -e MYSQL_DATABASEerp_db mysql:8.0.33root 密码设为123456数据库名erp_dbMaven3.8.6官网下载mvn -v验证低于 3.6.3 会因插件版本冲突编译失败IDEIntelliJ IDEA 2023.2社区版即可需安装 Lombok 插件Settings → Plugins → 搜索 Lombok2.2 数据库初始化执行sql/erp_init.sql的四个致命细节源码包中sql/erp_init.sql是唯一初始化脚本但它不是“一键执行就完事”。我踩过坑直接在 Navicat 里右键执行结果sys_user表插入失败因为脚本里INSERT INTO sys_user (...) VALUES (...);的末尾少了分号而 Navicat 默认关闭“每条语句单独执行”选项。正确姿势如下# 进入 MySQL 命令行确保已创建 erp_db 数据库 mysql -u root -p123456 erp_db # 在 MySQL 命令行内执行注意必须用 source 命令且路径用正斜杠 mysql source D:/projects/java-erp/sql/erp_init.sql;逻辑说明source命令会逐行解析 SQL 文件自动处理多语句分隔而 GUI 工具常把整个文件当单条语句提交遇到CREATE TABLE后紧跟INSERT时易出错。该脚本共创建 23 张表含purchase_order,sale_order,inventory_stock,finance_account并预置 5 条测试数据管理员账号admin/123456默认仓库WH-001常用商品iPhone14/华为Mate60。执行后检查SELECT COUNT(*) FROM sys_user;应返回1仅管理员若返回0说明脚本未执行成功。2.3 关键配置项修改application.yml 里这 3 行决定你能否登录src/main/resources/application.yml是启动命脉但文档没告诉你哪几行必须改。以下是实测必改项其他如 Redis、邮件配置可留空spring: datasource: url: jdbc:mysql://localhost:3306/erp_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse username: root password: 123456 # ↓↓↓ 这行必须删掉或注释否则启动报错Failed to bind properties under spring.redis to org.springframework.boot.autoconfigure.data.redis.RedisProperties # redis: # host: localhost # port: 6379 # ↓↓↓ 这行必须改为 true否则登录页验证码始终显示“验证码错误” kaptcha: enable: true # ↓↓↓ 这行必须设置为 false否则新用户注册时抛出异常java.lang.IllegalStateException: No instances available for user-service eureka: client: enabled: false参数说明url中serverTimezoneAsia/Shanghai解决时间字段存入 MySQL 后变成 00:00:00 的经典问题allowPublicKeyRetrievaltrue是 MySQL 8.0 驱动强制要求kaptcha.enable: true控制验证码开关源码中登录接口/login会校验KaptchaFilter若为 false 则过滤器跳过但前端仍发送验证码字段导致校验失败eureka.client.enabled: false因为该系统未集成 Eureka 注册中心保留 true 会导致启动时疯狂重试连接http://localhost:8761/eureka/拖慢启动速度并报 WARN 日志。2.4 启动验证如何确认不是“假启动”不要只看控制台输出Started Application in X seconds就以为成功。真正验证需三步端口监听检查netstat -ano | findstr :8080Windows或lsof -i :8080macOS/Linux确认 PID 对应的是你的 Java 进程健康端点探测浏览器访问http://localhost:8080/actuator/health返回{status:UP}且无diskSpace或db为DOWN登录链路打通访问http://localhost:8080/login输入admin/123456若跳转至/index且左上角显示“欢迎管理员”说明 Session、Shiro 权限、菜单加载全部正常。注意首次登录后系统会自动生成sys_log表记录操作日志。若SELECT * FROM sys_log ORDER BY create_time DESC LIMIT 1;返回login类型日志证明后端鉴权链路完整。3. 核心业务模块拆解采购、销售、库存三大主干如何用 MyBatis-Plus 实现状态机驱动这套源码最值得细读的不是 CRUD而是如何用 MyBatis-Plus 的TableField(fill FieldFill.INSERT)和Transactional组合把“采购入库”这种多步骤操作变成原子性事务。比如采购订单从“待审核”到“已入库”涉及purchase_order表状态更新、inventory_stock表数量增加、finance_account表应付账款生成——三张表写入必须全部成功或全部回滚。下面以采购模块为例带你看透设计逻辑。3.1 采购订单状态机枚举定义 Service 层状态流转校验状态不是靠字符串硬编码而是用PurchaseOrderStatusEnum.java枚举严格约束public enum PurchaseOrderStatusEnum { DRAFT(0, 草稿), PENDING_APPROVAL(1, 待审核), APPROVED(2, 已审核), PARTIALLY_RECEIVED(3, 部分入库), FULLY_RECEIVED(4, 全部入库), CANCELLED(5, 已取消); private final int code; private final String desc; PurchaseOrderStatusEnum(int code, String desc) { this.code code; this.desc desc; } // getter 方法省略 }关键在PurchaseOrderService.java的receiveGoods()方法Transactional(rollbackFor Exception.class) public boolean receiveGoods(Long orderId, ListReceiveItemDTO items) { // 1. 校验订单当前状态是否允许入库 PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (!order.getStatus().equals(PurchaseOrderStatusEnum.APPROVED.getCode())) { throw new BusinessException(订单状态非【已审核】不可执行入库操作); } // 2. 批量更新库存核心避免超卖 for (ReceiveItemDTO item : items) { // 先查当前库存悲观锁 InventoryStock stock inventoryStockMapper.selectOne( new QueryWrapperInventoryStock() .eq(goods_id, item.getGoodsId()) .eq(warehouse_id, item.getWarehouseId()) ); if (stock null) { throw new BusinessException(商品ID item.getGoodsId() 在仓库 item.getWarehouseId() 无库存记录); } // 更新库存数量MyBatis-Plus 自动拼接 SET stock_quantity stock_quantity ? stock.setStockQuantity(stock.getStockQuantity() item.getQuantity()); inventoryStockMapper.updateById(stock); } // 3. 更新订单状态为【部分入库】或【全部入库】 int totalReceived items.stream().mapToInt(ReceiveItemDTO::getQuantity).sum(); int totalOrdered order.getTotalQuantity(); // 订单总数量 int newStatus (totalReceived totalOrdered) ? PurchaseOrderStatusEnum.FULLY_RECEIVED.getCode() : PurchaseOrderStatusEnum.PARTIALLY_RECEIVED.getCode(); order.setStatus(newStatus); purchaseOrderMapper.updateById(order); // 4. 生成应付账款调用 finance 模块 financeAccountService.createPayable(orderId, items); return true; }逻辑说明Transactional保证整个方法内所有 DB 操作原子性selectOne(...)加了QueryWrapper但未显式加锁实际依赖 MySQL 的READ-COMMITTED隔离级别 UPDATE语句隐式行锁状态校验放在最前避免无效操作financeAccountService.createPayable()是另一个 Service 调用其内部也加了Transactional但因同属一个事务上下文不会开启新事务默认PROPAGATION_REQUIRED。3.2 销售出库的并发控制库存扣减的两种实现与选型理由销售出库面临高并发场景如促销秒杀源码提供两种模式默认启用“乐观锁”方案方案实现方式适用场景缺陷乐观锁默认inventory_stock表加version字段UPDATE ... SET stock_quantity ?, version version 1 WHERE id ? AND version ?并发量中等100 TPS网络延迟稳定高并发时大量OptimisticLockException需重试逻辑Redis 分布式锁备用RedisTemplate.opsForValue().setIfAbsent(stock_lock:goods_1001, 1, 10, TimeUnit.SECONDS)并发量极高500 TPS需强一致性增加 Redis 依赖锁续期复杂源码中SaleOrderService.java的shipGoods()方法默认走乐观锁// 乐观锁扣减库存简化版 public boolean shipGoods(Long orderId) { SaleOrder order saleOrderMapper.selectById(orderId); for (SaleOrderItem item : order.getItems()) { // 查询当前库存带 version InventoryStock stock inventoryStockMapper.selectById(item.getStockId()); if (stock.getStockQuantity() item.getQuantity()) { throw new BusinessException(商品库存不足ID item.getGoodsId()); } // 执行带 version 校验的更新 UpdateWrapperInventoryStock wrapper new UpdateWrapper(); wrapper.eq(id, stock.getId()) .eq(version, stock.getVersion()) // 关键只更新 version 匹配的行 .set(stock_quantity, stock.getStockQuantity() - item.getQuantity()) .set(version, stock.getVersion() 1); int updated inventoryStockMapper.update(null, wrapper); if (updated 0) { throw new BusinessException(库存扣减失败请重试); // version 不匹配说明已被其他线程修改 } } // 更新订单状态... return true; }参数说明version字段在InventoryStock实体类中标注VersionMyBatis-Plus 自动识别并注入到 SQL 中。若updated 0说明WHERE version ?条件不成立即库存已被其他请求修改此时必须抛异常由上层重试前端需实现重试按钮或自动轮询。3.3 库存盘点差异处理为什么inventory_adjustment表设计成“正向调整”而非“差额”盘点不是简单SET stock_quantity ?而是记录调整原因。源码中inventory_adjustment表结构如下字段类型说明idBIGINT PK主键goods_idBIGINT商品IDwarehouse_idBIGINT仓库IDbefore_quantityINT盘点前数量快照值after_quantityINT盘点后数量人工录入adjustment_quantityINT调整量 after - before可正可负reason_typeTINYINT原因类型1损耗、2盘盈、3盘亏、4录入错误operator_idBIGINT操作人IDcreate_timeDATETIME创建时间关键逻辑在InventoryAdjustmentService.javapublic void submitAdjustment(AdjustmentDTO dto) { // 1. 查询盘点前库存快照 InventoryStock stock inventoryStockMapper.selectOne( new QueryWrapperInventoryStock() .eq(goods_id, dto.getGoodsId()) .eq(warehouse_id, dto.getWarehouseId()) ); // 2. 计算调整量 int adjustment dto.getAfterQuantity() - stock.getStockQuantity(); // 3. 插入调整记录 InventoryAdjustment adjustmentRecord new InventoryAdjustment(); adjustmentRecord.setGoodsId(dto.getGoodsId()); adjustmentRecord.setWarehouseId(dto.getWarehouseId()); adjustmentRecord.setBeforeQuantity(stock.getStockQuantity()); adjustmentRecord.setAfterQuantity(dto.getAfterQuantity()); adjustmentRecord.setAdjustmentQuantity(adjustment); adjustmentRecord.setReasonType(dto.getReasonType()); adjustmentRecord.setOperatorId(dto.getOperatorId()); inventoryAdjustmentMapper.insert(adjustmentRecord); // 4. 更新库存注意这里不是 SET stock_quantity after_quantity而是 adjustment_quantity stock.setStockQuantity(stock.getStockQuantity() adjustment); inventoryStockMapper.updateById(stock); }为什么不用SET stock_quantity after_quantity因为after_quantity是人工录入值可能包含误差。若直接覆盖历史调整痕迹丢失。而 adjustment_quantity保证每次调整都是增量配合inventory_adjustment表可追溯所有变更例如某商品被多次盘盈/盘亏最终库存 初始值 Σ(adjustment_quantity)。4. 避坑指南我在客户现场踩过的 5 个真实翻车点每个都附带血泪解决方案这套源码看似结构清晰但真实部署时有 5 个高频翻车点轻则登录失败重则数据错乱。以下是我帮客户紧急修复的实录按发生频率排序4.1 现象登录页验证码图片显示为红叉F12 查看 network 发现GET /kaptcha.jpg返回 404原因KaptchaController.java中GetMapping(/kaptcha.jpg)的路径映射与 Spring Boot 2.7.x 的静态资源规则冲突。Spring Boot 默认将/kaptcha.jpg视为静态资源尝试从static/目录查找而 Kaptcha 生成器实际在内存中动态生成。解决在application.yml中添加静态资源排除配置spring: web: resources: static-locations: classpath:/static/,classpath:/public/ # ↓↓↓ 关键排除 kaptcha 路径让请求落到 Controller mvc: static-path-pattern: /**并在KaptchaController类上加Controller非RestController确保GetMapping生效。4.2 现象采购入库成功但库存数量没变inventory_stock表stock_quantity字段值不变原因inventory_stock表的stock_quantity字段在数据库中定义为INT UNSIGNED而 Java 实体类InventoryStock.java中对应字段为Integer可为负数。当执行stock_quantity stock_quantity (-10)时MySQL 报错ERROR 1264 (22003): Out of range value for column stock_quantity at row 1但 MyBatis-Plus 默认吞掉该异常只返回updateCount0。解决修改数据库字段ALTER TABLE inventory_stock MODIFY COLUMN stock_quantity INT;去掉 UNSIGNED在InventoryStockMapper.java的updateById方法后加日志int result updateById(entity); if (result 0) { log.error(库存更新失败ID{}, 当前数量{}, entity.getId(), entity.getStockQuantity()); }4.3 现象导出 Excel 报错java.lang.NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook原因pom.xml中poi依赖版本为3.17但ExcelExportUtil.java使用了 POI 4.1.2 的XSSFWorkbook构造函数new XSSFWorkbook(OPCPackage.open(inputStream))3.17 不支持 OPCPackage。解决升级 POI 依赖dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version4.1.2/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version /dependency4.4 现象修改商品价格后历史销售单据的金额未联动更新导致财务对账不平原因源码设计原则是“销售单据金额固化”即开单时sale_order_item.amount字段存入当时价格后续商品价格变更不影响历史单据。但客户误以为系统会自动重算导致对账差异。解决在商品编辑页面加醒目提示“修改价格仅影响新生成单据历史单据金额保持不变”提供“历史单据重定价”工具FinanceService.repriceHistoricalOrders(goodsId, newPrice)但需人工触发并二次确认。4.5 现象Linux 服务器启动后http://ip:8080/login可访问但点击登录按钮无响应Network 中POST /login状态为(pending)原因服务器防火墙未开放 8080 端口或云服务器安全组未放行。更隐蔽的是application.yml中server.address配置为127.0.0.1导致 Tomcat 只监听本地回环外部无法访问。解决检查防火墙sudo ufw statusUbuntu或sudo firewall-cmd --list-portsCentOS开放 8080修改application.ymlserver: port: 8080 address: 0.0.0.0 # 关键监听所有网卡非 127.0.0.15. 进阶技巧用 Logback 实现操作日志精准追踪与性能瓶颈定位这套源码的日志不是简单log.info()而是通过 Logback 的 MDCMapped Diagnostic Context机制把用户 ID、单据号、请求耗时等上下文注入每条日志让排查问题像查快递物流一样直观。我把它拆成三个层次来用效果立竿见影。5.1 第一层MDC 注入——让每条日志自带“身份证”LogFilter.java是入口它在请求开始时把关键信息塞进 MDCComponent public class LogFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; // 从 Session 或 Token 提取用户ID Long userId getCurrentUserId(httpRequest); // 生成唯一请求ID String requestId UUID.randomUUID().toString().replace(-, ).substring(0, 12); // 注入 MDC MDC.put(userId, String.valueOf(userId)); MDC.put(requestId, requestId); MDC.put(uri, httpRequest.getRequestURI()); MDC.put(method, httpRequest.getMethod()); try { long startTime System.currentTimeMillis(); chain.doFilter(request, response); long duration System.currentTimeMillis() - startTime; MDC.put(duration, String.valueOf(duration)); // 耗时毫秒 } finally { MDC.clear(); // 必须清理否则线程复用时污染 } } }效果日志格式logback-spring.xml中定义为%d{HH:mm:ss.SSS} [%thread] [%X{userId}] [%X{requestId}] %-5level %logger{36} - %msg%n输出示例14:22:35.123 [http-nio-8080-exec-3] [1001] [a1b2c3d4e5f6] INFO c.e.s.p.PurchaseOrderService - 采购订单[PO20240501001]入库完成耗时234ms一眼看出是哪个用户、哪个请求、哪个单据、耗时多少。5.2 第二层异步日志 文件滚动——避免日志 IO 拖垮主线程logback-spring.xml中AsyncAppender配置是关键appender nameASYNC_FILE classch.qos.logback.classic.AsyncAppender discardingThreshold0/discardingThreshold !-- 不丢弃日志 -- queueSize1000/queueSize !-- 队列大小 -- includeCallerDatatrue/includeCallerData appender-ref refFILE/ /appender appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/erp-app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/erp-app.%d{yyyy-MM-dd}.%i.log/fileNamePattern timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize100MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy maxHistory30/maxHistory /rollingPolicy encoder pattern%d{HH:mm:ss.SSS} [%thread] [%X{userId}] [%X{requestId}] %-5level %logger{36} - %msg%n/pattern /encoder /appender参数说明queueSize1000异步队列容量过高占内存过低易阻塞maxFileSize100MB单个日志文件最大 100MB超限自动滚动maxHistory30保留最近 30 天日志自动清理旧文件。5.3 第三层自定义 Appender 实现“慢 SQL”告警源码自带SlowSqlAppender.java它监听org.mybatis.spring.SqlSessionTemplate的执行耗时public class SlowSqlAppender extends AppenderBaseILoggingEvent { private static final long SLOW_THRESHOLD_MS 500L; // 超过 500ms 认定为慢 SQL Override protected void append(ILoggingEvent event) { String message event.getFormattedMessage(); if (message.contains( Preparing:) event.getLevel().equals(Level.DEBUG)) { // 解析 SQL 执行耗时日志格式DEBUG o.m.s.t.SqlSessionUtils - JDBC Connection [HikariProxyConnection...] will be managed by Spring // 后续行含DEBUG o.m.s.t.SqlSessionUtils - Preparing: SELECT * FROM purchase_order WHERE id ? // 再下一行DEBUG o.m.s.t.SqlSessionUtils - Parameters: 123(Long) // 再下一行DEBUG o.m.s.t.SqlSessionUtils - Columns: id, status, ... // 再下一行DEBUG o.m.s.t.SqlSessionUtils - Total: 1 // 我们需要捕获从 Preparing 到 Total 之间的耗时但 Logback 无法跨行解析故改用 AOP 拦截 } } }真实做法我推荐的放弃日志解析改用Around切面拦截Mapper方法Aspect Component public class SqlPerformanceAspect { private static final Logger logger LoggerFactory.getLogger(SqlPerformanceAspect.class); Around(annotation(org.springframework.transaction.annotation.Transactional)) public Object logSqlExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long duration System.currentTimeMillis() - start; if (duration 500) { String methodName joinPoint.getSignature().toShortString(); logger.warn(慢SQL警告{} 执行耗时 {}ms, methodName, duration); // 可在此处发钉钉告警或写入 slow_sql_log 表 } } } }为什么这是后悔药级别的技巧上周客户投诉“系统变慢”我直接 grepslow.*ms日志3 分钟定位到PurchaseOrderMapper.selectByStatus方法平均耗时 1200ms原因是status字段未建索引。加索引后采购单查询从 1.2s 降到 45ms。没有这个切面你得翻几十万行日志找规律。从那以后我每次上线新功能都强制走一遍grep -r slow.*ms logs/再结合top -Hp pid查 CPU 占用最高的线程基本 10 分钟内锁定瓶颈。这套 Java 进销存 ERP 源码的价值不在于它有多“高大上”而在于它把真实业务里的脏活累活——状态校验、并发控制、日志追踪——都用可复用的代码写清楚了。希望帮到你。本文还有配套的精品资源点击获取
返回列表