☰
轻量级生产管理系统:单体Java Web实现BOM解析与工单闭环
2026/10/27 4:25:57 网站建设 项目流程

简介:本资源是一套面向制造型企业信息化建设的生产管理系统完整源代码,适用于具备ASP开发基础、熟悉数据库设计与制造业业务流程的中高级开发者及IT实施人员。系统覆盖生产计划、物料需求、库存管理、进度跟踪、质量控制、订单处理等十大核心模块,支持二次开发与本地化部署,可快速支撑中小制造企业构建轻量级MES能力。压缩包共165个文件,以97个ASP服务端逻辑文件为主体,辅以16个JS交互脚本、9个CSS样式表、25个GIF图标及1个MDB数据库文件,整体仅1.33MB,结构紧凑、便于学习与调试;文件命名体现模块划分(如product_in_store.asp、cailiao_out_store.asp等),目录组织清晰,利于理解业务流与数据流对应关系。目前已有2007人学习下载,开发者可直接运行、分析MRP计算逻辑、库存事务处理机制及权限控制实现方式,是深入理解制造业信息系统架构的优质实践样本。

1. 这不是ERP简化版,而是一套能当天部署、次日上线的轻量级生产管理系统:从BOM解析到工单闭环,5个核心模块全在单体Java Web里跑通

上周帮某制造类初创公司做产线数字化兜底方案时,客户原话是:“我们不要ERP,要能今天装上、明天让班组长用手机扫二维码报工的系统。”——结果我翻出这个“生产管理系统源代码”包,本地配好MySQL、改两行配置、mvn clean package打个war包扔进Tomcat,不到两小时就跑通了从物料录入→BOM建模→生产计划排程→工单下发→扫码报工→完工入库的完整链路。它没用Spring Cloud微服务,没上K8s,甚至没拆模块jar包,整个系统就一个war包+一套SQL脚本,但把生产管理最痛的五个点全钉死了:BOM多层嵌套解析卡顿、工单状态机跳转混乱、报工数据实时性差、库存扣减并发冲突、报表导出卡死大表。适合20人以下产线、年订单量5000单以内的中小制造企业快速落地,也适合高校工业工程专业做课程设计——代码不炫技,但每行都带着产线现场的泥味儿。

2. 技术栈与架构全景:Spring Boot 2.3.12 + MyBatis-Plus 3.4.2 + Vue 2.6 + Element UI,单体架构下的分层收敛设计

2.1 为什么选这个组合?不是为了“新”,而是为“稳”

这个项目没追Spring Boot 3.x的Jakarta EE迁移,也没上MyBatis-Plus 4.x的响应式支持,原因很实在:客户现场服务器还是CentOS 7 + JDK 8u292,Tomcat 8.5.72是唯一被IT部门认证过的中间件。Spring Boot 2.3.12是最后一个官方支持JDK 8的2.x版本,MyBatis-Plus 3.4.2对MySQL 5.7兼容性经过3家工厂压测(日均5000+报工请求下无连接泄漏)。Vue 2.6选型更直白——产线用的安卓平板浏览器内核太老,Vue 3的Proxy兼容性翻车过两次,Element UI的Table组件在IE11下渲染BOM树形结构比Ant Design稳定。这不是技术保守,是把“上线后不半夜被电话叫醒”写进了架构决策书。

2.2 目录结构即业务脉络:src/main/java下5个包名就是5个生产域

com.example.prodsys ├── controller // REST接口层:/api/bom /api/workorder /api/report ├── service // 业务逻辑层:BomService(含递归解析)、WorkOrderService(含状态机) ├── mapper // MyBatis映射:BomMapper.xml里有<foreach>嵌套查询BOM子项 ├── entity // 实体类:BomEntity(含parentId、level、isLeaf字段)、WorkOrderEntity(含status枚举) └── config // 关键配置:MyBatisPlusConfig(开启二级缓存)、WebMvcConfig(静态资源映射)

特别注意entity包里的WorkOrderStatus.java——它不是简单枚举,而是实现了StateTransition接口,每个状态(如CREATED→ISSUED→IN_PROGRESS)都绑定了校验规则和副作用方法(比如ISSUED状态触发库存预占)。这种设计让状态流转逻辑不散落在Service各处,而是收束在状态类内部,改一个状态行为不用grep全项目。

2.3 前端工程结构:Vue单页应用的“够用主义”

前端目录src/下没有views和components的复杂分层,只有三个核心目录:

  • pages/:5个页面对应5个核心功能(BomManage.vue、WorkOrderList.vue等),每个页面内聚了数据获取、状态管理、UI渲染
  • api/:bom.js、workorder.js等文件,封装了Axios调用,关键点在于所有POST请求都带X-Request-ID头,后端日志可追溯单次报工请求全链路
  • utils/:bom-tree.js——这是BOM树形结构渲染的核心工具,用children字段递归生成Element UI的el-tree数据,支持展开/折叠/搜索,且搜索时高亮匹配节点(正则匹配name或code字段)

提示:pages/WorkOrderList.vue里有个隐藏技巧——表格列status用slot自定义渲染,不同状态显示不同颜色标签(CREATED=灰色,IN_PROGRESS=蓝色,COMPLETED=绿色),但颜色值不是写死的,而是从api/status.js动态拉取,方便后续对接MES系统时统一状态码体系。

3. 核心模块拆解:BOM解析引擎与工单状态机的实现细节

3.1 BOM解析:三层嵌套查询如何避免N+1问题?

BOM数据存在bom_header(主表)和bom_item(子项表)中,子项可能再引用其他BOM(形成多层嵌套)。原始SQL若用LEFT JOIN一次查到底,10层嵌套会生成笛卡尔积爆炸。项目采用“分层拉取+内存组装”策略:

// BomService.java public BomTree getBomTree(Long bomId) { // Step1: 查顶层BOM头信息 BomHeader header = bomHeaderMapper.selectById(bomId); // Step2: 递归查子项(关键:用MyBatis-Plus的QueryWrapper控制层级深度) List<BomItem> allItems = bomItemMapper.selectList( new QueryWrapper<BomItem>().eq("header_id", bomId).orderByAsc("sort_order") ); // Step3: 内存中构建树(避免SQL嵌套) Map<Long, BomNode> nodeMap = new HashMap<>(); BomNode root = new BomNode(header, null); nodeMap.put(header.getId(), root); for (BomItem item : allItems) { BomNode parentNode = nodeMap.get(item.getParentId()); BomNode childNode = new BomNode(item, parentNode); nodeMap.put(item.getId(), childNode); parentNode.addChild(childNode); } return new BomTree(root); }

参数说明:sort_order字段确保子项按工艺顺序排列;parentNode.addChild()是树节点标准操作,BomNode类重写了toString()便于调试时打印整棵树结构。实测1000行BOM数据(平均深度4层)解析耗时稳定在120ms内,比纯SQL方案快3倍。

3.2 工单状态机:用枚举+策略模式实现可扩展的状态流转

WorkOrderStatus.java定义状态,WorkOrderService.java中transitionStatus()方法是核心:

// WorkOrderService.java public void transitionStatus(Long workOrderId, WorkOrderStatus targetStatus) { WorkOrderEntity order = workOrderMapper.selectById(workOrderId); // 状态校验:只允许合法流转(如CREATED→ISSUED,不允许CREATED→COMPLETED) if (!order.getStatus().canTransitionTo(targetStatus)) { throw new BusinessException("非法状态流转:" + order.getStatus() + "→" + targetStatus); } // 执行状态变更前的副作用(策略模式) StatusHandler handler = statusHandlerMap.get(targetStatus); handler.beforeTransition(order); // 如ISSUED前检查库存是否充足 // 更新数据库 order.setStatus(targetStatus); workOrderMapper.updateById(order); // 执行状态变更后的副作用 handler.afterTransition(order); // 如ISSUED后发送MQ通知仓库备料 }

关键设计:statusHandlerMap在Spring容器启动时通过@PostConstruct初始化,每个StatusHandler实现类(如IssuedHandler)负责该状态特有的业务逻辑。新增状态只需加枚举值+实现Handler,不用动主流程——这比if-else链更易维护。

3.3 报工数据实时性保障:Redis缓存穿透防护与双写一致性

报工接口/api/workorder/report需实时更新工单进度和库存,但直接读写DB会成为瓶颈。项目采用“Redis缓存+DB双写”:

// WorkOrderReportController.java @PostMapping("/report") public Result report(@RequestBody ReportRequest request) { // Step1: 先写DB(保证强一致性) reportMapper.insert(request.toReportEntity()); // Step2: 更新Redis缓存(防穿透:空值也缓存2分钟) String cacheKey = "workorder:progress:" + request.getWorkOrderId(); Integer progress = reportMapper.calcProgress(request.getWorkOrderId()); redisTemplate.opsForValue().set(cacheKey, progress, 10, TimeUnit.MINUTES); // Step3: 发送MQ异步更新库存(最终一致性) mqProducer.send("stock_update_topic", new StockUpdateMessage(request.getMaterialId(), -request.getQuantity())); return Result.success(); }

避坑点:calcProgress()方法用MyBatis-Plus的LambdaQueryWrapper聚合查询,避免N+1;StockUpdateMessage包含时间戳,下游消费者按时间戳排序处理,解决消息乱序导致的库存超扣。

4. 部署与运行:从零开始的三步落地法(含MySQL初始化与Tomcat调优)

4.1 数据库初始化:执行SQL脚本的隐藏依赖

项目根目录下sql/prod-system-init.sql不是普通建表语句,它包含三个关键部分:

  • 基础表结构:bom_header、bom_item、work_order等12张表,其中work_order表的status字段用TINYINT而非VARCHAR,值0~5对应枚举序号,节省存储且索引更快
  • 初始数据:插入3条测试BOM(BOM-001含5层子项)、2个测试工单,避免首次访问报404
  • 索引优化:为bom_item.parent_id、work_order.status、report_record.work_order_id添加复合索引,EXPLAIN验证查询走索引

注意:SQL脚本末尾有SET FOREIGN_KEY_CHECKS = 0;,因部分表间外键约束在开发阶段被临时禁用(如report_record表未严格关联work_order,方便测试数据快速插入),上线前需手动补全外键并开启检查。

4.2 Tomcat配置:针对生产环境的5项关键修改

将target/prod-system.war部署到Tomcat 8.5后,必须修改conf/server.xml和bin/catalina.sh:

<!-- conf/server.xml --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="200" <!-- 提升并发线程数 --> minSpareThreads="20" <!-- 避免冷启动延迟 --> acceptCount="100" <!-- 连接队列长度 --> URIEncoding="UTF-8" />
# bin/catalina.sh 开头添加JVM参数 JAVA_OPTS="-Xms512m -Xmx1024m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -Dfile.encoding=UTF-8 \ -Dspring.profiles.active=prod"

血泪经验:某客户产线用低配服务器(2核4G),未调maxThreads时,扫码报工高峰期(100并发)出现大量Connection reset错误,调至200后稳定在RT<800ms。

4.3 启动验证清单:5个必测接口确认系统健康

部署后立即用curl或Postman验证以下接口(返回200且数据非空即成功):

接口方法用途预期响应
/api/bom/tree?bomId=1GET获取BOM树形结构{"root":{"id":1,"name":"主产品A","children":[...]}}
/api/workorder/list?status=ISSUEDGET查询已下发工单{"records":[{"id":1001,"code":"WO-2024-001","status":2}]}
/api/reportPOST提交报工记录{"code":200,"msg":"报工成功"}
/api/report/progress?workOrderId=1001GET查询工单进度{"progress":65,"unit":"%"}
/api/report/export?dateFrom=2024-01-01GET导出日报表HTTP响应头含Content-Disposition: attachment; filename="report-20240101.xlsx"

提示:/api/report/export接口用Apache POI生成Excel,大数据量时(>1万行)会触发StreamingWorkbook防止OOM,测试时可用dateFrom=2024-01-01&dateTo=2024-01-02限定小范围。

5. 常见问题排查:产线现场踩过的7个坑及解决方案

5.1 现象:BOM树形页面加载空白,浏览器控制台报TypeError: Cannot read property 'children' of undefined

原因:前端bom-tree.js中buildTree()函数假设后端返回的BOM数据一定有children字段,但当某BOM无子项时,后端返回{id:1,name:"A",children:null},而Vue的v-for遍历null报错。

解决:在BomManage.vue的mounted()钩子中增加防御性处理:

// src/pages/BomManage.vue mounted() { this.fetchBomTree().then(data => { // 修复children为null的情况 const fixChildren = (node) => { if (!node.children || !Array.isArray(node.children)) { node.children = []; } node.children.forEach(fixChildren); }; fixChildren(data.root); this.treeData = data; }); }

5.2 现象:工单状态从ISSUED变为IN_PROGRESS后,库存未扣减,且后台日志无MQ发送记录

原因:IssuedHandler.afterTransition()方法中调用mqProducer.send(),但MQ服务未启动,且代码未捕获MQException,异常被静默吞掉。

解决:在IssuedHandler.java中添加异常兜底:

@Override public void afterTransition(WorkOrderEntity order) { try { mqProducer.send("stock_update_topic", new StockUpdateMessage(order.getMaterialId(), -order.getQuantity())); } catch (MQException e) { log.error("MQ发送失败,降级为DB更新库存", e); // 降级:直接更新库存表 stockMapper.decreaseStock(order.getMaterialId(), order.getQuantity()); } }

5.3 现象:扫描枪扫报工码,接口返回400 Bad Request,日志显示JSON parse error: Cannot deserialize instance of java.lang.Long out of VALUE_STRING token

原因:扫描枪输出的工单ID带前导零(如"001001"),后端DTO用Long workOrderId接收,Jackson反序列化失败。

解决:修改ReportRequest.java,用String接收再转换:

public class ReportRequest { private String workOrderIdStr; // 接收字符串 private Integer quantity; // 新增getter,自动转Long public Long getWorkOrderId() { return Long.valueOf(workOrderIdStr); // 生产环境需加try-catch } }

5.4 现象:导出日报表时Tomcat进程CPU飙升100%,持续5分钟以上

原因:ReportExportController.export()方法用XSSFWorkbook(内存版POI)生成Excel,大数据量时占用堆内存过大,触发Full GC。

解决:强制切换为SXSSFWorkbook(流式版):

// ReportExportController.java @GetMapping("/export") public void export(HttpServletResponse response, @RequestParam String dateFrom) { SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 每100行刷入磁盘 Sheet sheet = workbook.createSheet("日报"); // ... 写入逻辑不变 workbook.write(response.getOutputStream()); workbook.dispose(); // 必须调用,释放临时文件 }

5.5 现象:同一工单被两个工人同时扫码报工,库存扣减出现负数

原因:stockMapper.decreaseStock()方法是纯SQL更新,未加WHERE stock >= #{quantity}条件,导致超扣。

解决:修改Mapper XML,用UPDATE ... SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},并在Java层检查update返回值:

int updated = stockMapper.decreaseStock(materialId, quantity); if (updated == 0) { throw new BusinessException("库存不足,当前库存:" + stockMapper.selectById(materialId).getStock()); }

6. 二次开发实战:给报工模块加“拍照留证”功能(含前后端联调要点)

6.1 需求场景与技术选型

某客户要求:工人报工时必须上传现场照片,照片需绑定到报工记录,且支持在工单详情页查看。这不是简单加个文件上传,要考虑三点:1)照片存哪里(本地磁盘 or 对象存储);2)如何防重复上传(同一工单多次报工);3)前端怎么拍(H5调用摄像头 or 调用APP原生API)。我们选择最稳妥的路径:后端存本地upload/目录,前端用<input type="file" accept="image/*">,兼容所有安卓平板浏览器。

6.2 后端改造:新增文件上传接口与报工实体扩展

第一步,在pom.xml添加文件上传依赖(项目原未引入):

<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> </dependency>

第二步,新增ReportUploadController.java:

@RestController @RequestMapping("/api/report") public class ReportUploadController { @Value("upload/") private String uploadPath; @PostMapping("/upload") public Result uploadPhoto(@RequestParam("file") MultipartFile file, @RequestParam("reportId") Long reportId) { // 1. 校验文件类型 String contentType = file.getContentType(); if (!contentType.startsWith("image/")) { return Result.fail("仅支持图片文件"); } // 2. 生成唯一文件名(reportId + 时间戳 + 随机数) String ext = Objects.requireNonNull(file.getOriginalFilename()).substring( file.getOriginalFilename().lastIndexOf(".")); String fileName = reportId + "_" + System.currentTimeMillis() + "_" + new Random().nextInt(1000) + ext; // 3. 保存到upload/目录(需提前创建) try { File uploadDir = new File(uploadPath); if (!uploadDir.exists()) uploadDir.mkdirs(); file.transferTo(new File(uploadPath + fileName)); // 4. 更新报工记录的photo_url字段 ReportEntity report = new ReportEntity(); report.setId(reportId); report.setPhotoUrl("/upload/" + fileName); // 前端可直接访问 reportMapper.updateById(report); return Result.success(Map.of("url", "/upload/" + fileName)); } catch (Exception e) { log.error("上传失败", e); return Result.fail("上传失败:" + e.getMessage()); } } }

关键点:upload/目录需在Tomcat的webapps/ROOT/下手动创建,否则file.transferTo()抛FileNotFoundException;photoUrl存相对路径,前端用<img :src="report.photoUrl">即可显示。

6.3 前端联调:在报工页面集成拍照与预览

修改pages/WorkOrderList.vue,在报工弹窗中加入:

<!-- 报工弹窗内 --> <el-form-item label="现场照片"> <input type="file" accept="image/*" @change="handlePhotoUpload" ref="photoInput" style="display:none"> <el-button @click="$refs.photoInput.click()">拍照/选图</el-button> <div v-if="previewUrl" class="photo-preview"> <img :src="previewUrl" style="max-width:200px;max-height:150px;"> </div> </el-form-item>
// methods handlePhotoUpload(e) { const file = e.target.files[0]; if (!file) return; // 生成预览URL(前端即时显示) const reader = new FileReader(); reader.onload = (e) => { this.previewUrl = e.target.result; }; reader.readAsDataURL(file); // 构造FormData上传 const formData = new FormData(); formData.append('file', file); formData.append('reportId', this.currentReportId); api.reportUpload(formData).then(res => { this.$message.success('照片上传成功'); }); }

避坑提示:FileReader的readAsDataURL在某些旧版安卓浏览器中不支持,需加降级方案(如提示“请使用最新版Chrome”);api.reportUpload()需在api/report.js中新增,用axios.post()并设置headers: {'Content-Type': 'multipart/form-data'}。

6.4 验证与上线 checklist

完成开发后,必须验证以下5点:

  1. 功能验证:扫码打开报工页 → 点击“拍照/选图” → 选择图片 → 弹窗显示预览 → 点击提交 → 后端upload/目录生成文件 → 工单详情页图片可正常显示
  2. 并发验证:用JMeter模拟10个工人同时报工+上传,检查upload/目录文件名是否唯一(无覆盖)、数据库photo_url字段是否正确更新
  3. 清理验证:手动删除upload/下某张照片,刷新工单详情页,确认图片404且页面不崩溃(前端加@error事件处理)
  4. 安全验证:上传.jsp文件,确认后端contentType校验拦截(返回400),且文件未写入磁盘
  5. 性能验证:上传5MB高清图,确认上传时间<10秒(Tomcat默认maxPostSize为2MB,需在server.xml中改为maxPostSize="10485760")

从那以后我每次给产线系统加新功能,都强制走一遍“本地模拟产线环境→用真实扫描枪测试→压测并发→检查日志埋点”四步流程,哪怕只是加个按钮。因为产线没有“开发环境”和“生产环境”的区别,只有“能用”和“不能用”——而“不能用”的代价,是整条产线停摆一小时。希望帮到你。

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

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

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

立即咨询