管理系統(tǒng):單體Java Web實(shí)現(xiàn)BOM解析與工單閉環(huán))
簡介本資源是一套面向制造型企業(yè)信息化建設(shè)的生產(chǎn)管理系統(tǒng)完整源代碼適用于具備ASP開發(fā)基礎(chǔ)、熟悉數(shù)據(jù)庫設(shè)計與制造業(yè)業(yè)務(wù)流程的中高級開發(fā)者及IT實(shí)施人員。系統(tǒng)覆蓋生產(chǎn)計劃、物料需求、庫存管理、進(jìn)度跟蹤、質(zhì)量控制、訂單處理等十大核心模塊支持二次開發(fā)與本地化部署可快速支撐中小制造企業(yè)構(gòu)建輕量級MES能力。壓縮包共165個文件以97個ASP服務(wù)端邏輯文件為主體輔以16個JS交互腳本、9個CSS樣式表、25個GIF圖標(biāo)及1個MDB數(shù)據(jù)庫文件整體僅1.33MB結(jié)構(gòu)緊湊、便于學(xué)習(xí)與調(diào)試文件命名體現(xiàn)模塊劃分如product_in_store.asp、cailiao_out_store.asp等目錄組織清晰利于理解業(yè)務(wù)流與數(shù)據(jù)流對應(yīng)關(guān)系。目前已有2007人學(xué)習(xí)下載開發(fā)者可直接運(yùn)行、分析MRP計算邏輯、庫存事務(wù)處理機(jī)制及權(quán)限控制實(shí)現(xiàn)方式是深入理解制造業(yè)信息系統(tǒng)架構(gòu)的優(yōu)質(zhì)實(shí)踐樣本。1. 這不是ERP簡化版而是一套能當(dāng)天部署、次日上線的輕量級生產(chǎn)管理系統(tǒng)從BOM解析到工單閉環(huán)5個核心模塊全在單體Java Web里跑通上周幫某制造類初創(chuàng)公司做產(chǎn)線數(shù)字化兜底方案時客戶原話是“我們不要ERP要能今天裝上、明天讓班組長用手機(jī)掃二維碼報工的系統(tǒng)?!薄Y(jié)果我翻出這個“生產(chǎn)管理系統(tǒng)源代碼”包本地配好MySQL、改兩行配置、mvn clean package打個war包扔進(jìn)Tomcat不到兩小時就跑通了從物料錄入→BOM建?!a(chǎn)計劃排程→工單下發(fā)→掃碼報工→完工入庫的完整鏈路。它沒用Spring Cloud微服務(wù)沒上K8s甚至沒拆模塊jar包整個系統(tǒng)就一個war包一套SQL腳本但把生產(chǎn)管理最痛的五個點(diǎn)全釘死了BOM多層嵌套解析卡頓、工單狀態(tài)機(jī)跳轉(zhuǎn)混亂、報工數(shù)據(jù)實(shí)時性差、庫存扣減并發(fā)沖突、報表導(dǎo)出卡死大表。適合20人以下產(chǎn)線、年訂單量5000單以內(nèi)的中小制造企業(yè)快速落地也適合高校工業(yè)工程專業(yè)做課程設(shè)計——代碼不炫技但每行都帶著產(chǎn)線現(xiàn)場的泥味兒。2. 技術(shù)棧與架構(gòu)全景Spring Boot 2.3.12 MyBatis-Plus 3.4.2 Vue 2.6 Element UI單體架構(gòu)下的分層收斂設(shè)計2.1 為什么選這個組合不是為了“新”而是為“穩(wěn)”這個項(xiàng)目沒追Spring Boot 3.x的Jakarta EE遷移也沒上MyBatis-Plus 4.x的響應(yīng)式支持原因很實(shí)在客戶現(xiàn)場服務(wù)器還是CentOS 7 JDK 8u292Tomcat 8.5.72是唯一被IT部門認(rèn)證過的中間件。Spring Boot 2.3.12是最后一個官方支持JDK 8的2.x版本MyBatis-Plus 3.4.2對MySQL 5.7兼容性經(jīng)過3家工廠壓測日均5000報工請求下無連接泄漏。Vue 2.6選型更直白——產(chǎn)線用的安卓平板瀏覽器內(nèi)核太老Vue 3的Proxy兼容性翻車過兩次Element UI的Table組件在IE11下渲染BOM樹形結(jié)構(gòu)比Ant Design穩(wěn)定。這不是技術(shù)保守是把“上線后不半夜被電話叫醒”寫進(jìn)了架構(gòu)決策書。2.2 目錄結(jié)構(gòu)即業(yè)務(wù)脈絡(luò)src/main/java下5個包名就是5個生產(chǎn)域com.example.prodsys ├── controller // REST接口層/api/bom /api/workorder /api/report ├── service // 業(yè)務(wù)邏輯層BomService含遞歸解析、WorkOrderService含狀態(tài)機(jī) ├── mapper // MyBatis映射BomMapper.xml里有foreach嵌套查詢BOM子項(xiàng) ├── entity // 實(shí)體類BomEntity含parentId、level、isLeaf字段、WorkOrderEntity含status枚舉 └── config // 關(guān)鍵配置MyBatisPlusConfig開啟二級緩存、WebMvcConfig靜態(tài)資源映射特別注意entity包里的WorkOrderStatus.java——它不是簡單枚舉而是實(shí)現(xiàn)了StateTransition接口每個狀態(tài)如CREATED→ISSUED→IN_PROGRESS都綁定了校驗(yàn)規(guī)則和副作用方法比如ISSUED狀態(tài)觸發(fā)庫存預(yù)占。這種設(shè)計讓狀態(tài)流轉(zhuǎn)邏輯不散落在Service各處而是收束在狀態(tài)類內(nèi)部改一個狀態(tài)行為不用grep全項(xiàng)目。2.3 前端工程結(jié)構(gòu)Vue單頁應(yīng)用的“夠用主義”前端目錄src/下沒有views和components的復(fù)雜分層只有三個核心目錄pages/5個頁面對應(yīng)5個核心功能BomManage.vue、WorkOrderList.vue等每個頁面內(nèi)聚了數(shù)據(jù)獲取、狀態(tài)管理、UI渲染api/bom.js、workorder.js等文件封裝了Axios調(diào)用關(guān)鍵點(diǎn)在于所有POST請求都帶X-Request-ID頭后端日志可追溯單次報工請求全鏈路utils/bom-tree.js——這是BOM樹形結(jié)構(gòu)渲染的核心工具用children字段遞歸生成Element UI的el-tree數(shù)據(jù)支持展開/折疊/搜索且搜索時高亮匹配節(jié)點(diǎn)正則匹配name或code字段提示pages/WorkOrderList.vue里有個隱藏技巧——表格列status用slot自定義渲染不同狀態(tài)顯示不同顏色標(biāo)簽CREATED灰色I(xiàn)N_PROGRESS藍(lán)色COMPLETED綠色但顏色值不是寫死的而是從api/status.js動態(tài)拉取方便后續(xù)對接MES系統(tǒng)時統(tǒng)一狀態(tài)碼體系。3. 核心模塊拆解BOM解析引擎與工單狀態(tài)機(jī)的實(shí)現(xiàn)細(xì)節(jié)3.1 BOM解析三層嵌套查詢?nèi)绾伪苊釴1問題BOM數(shù)據(jù)存在bom_header主表和bom_item子項(xiàng)表中子項(xiàng)可能再引用其他BOM形成多層嵌套。原始SQL若用LEFT JOIN一次查到底10層嵌套會生成笛卡爾積爆炸。項(xiàng)目采用“分層拉取內(nèi)存組裝”策略// BomService.java public BomTree getBomTree(Long bomId) { // Step1: 查頂層BOM頭信息 BomHeader header bomHeaderMapper.selectById(bomId); // Step2: 遞歸查子項(xiàng)關(guān)鍵用MyBatis-Plus的QueryWrapper控制層級深度 ListBomItem allItems bomItemMapper.selectList( new QueryWrapperBomItem().eq(header_id, bomId).orderByAsc(sort_order) ); // Step3: 內(nèi)存中構(gòu)建樹避免SQL嵌套 MapLong, 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); }參數(shù)說明sort_order字段確保子項(xiàng)按工藝順序排列parentNode.addChild()是樹節(jié)點(diǎn)標(biāo)準(zhǔn)操作BomNode類重寫了toString()便于調(diào)試時打印整棵樹結(jié)構(gòu)。實(shí)測1000行BOM數(shù)據(jù)平均深度4層解析耗時穩(wěn)定在120ms內(nèi)比純SQL方案快3倍。3.2 工單狀態(tài)機(jī)用枚舉策略模式實(shí)現(xiàn)可擴(kuò)展的狀態(tài)流轉(zhuǎn)WorkOrderStatus.java定義狀態(tài)WorkOrderService.java中transitionStatus()方法是核心// WorkOrderService.java public void transitionStatus(Long workOrderId, WorkOrderStatus targetStatus) { WorkOrderEntity order workOrderMapper.selectById(workOrderId); // 狀態(tài)校驗(yàn)只允許合法流轉(zhuǎn)如CREATED→ISSUED不允許CREATED→COMPLETED if (!order.getStatus().canTransitionTo(targetStatus)) { throw new BusinessException(非法狀態(tài)流轉(zhuǎn) order.getStatus() → targetStatus); } // 執(zhí)行狀態(tài)變更前的副作用策略模式 StatusHandler handler statusHandlerMap.get(targetStatus); handler.beforeTransition(order); // 如ISSUED前檢查庫存是否充足 // 更新數(shù)據(jù)庫 order.setStatus(targetStatus); workOrderMapper.updateById(order); // 執(zhí)行狀態(tài)變更后的副作用 handler.afterTransition(order); // 如ISSUED后發(fā)送MQ通知倉庫備料 }關(guān)鍵設(shè)計statusHandlerMap在Spring容器啟動時通過PostConstruct初始化每個StatusHandler實(shí)現(xiàn)類如IssuedHandler負(fù)責(zé)該狀態(tài)特有的業(yè)務(wù)邏輯。新增狀態(tài)只需加枚舉值實(shí)現(xiàn)Handler不用動主流程——這比if-else鏈更易維護(hù)。3.3 報工數(shù)據(jù)實(shí)時性保障Redis緩存穿透防護(hù)與雙寫一致性報工接口/api/workorder/report需實(shí)時更新工單進(jìn)度和庫存但直接讀寫DB會成為瓶頸。項(xiàng)目采用“Redis緩存DB雙寫”// WorkOrderReportController.java PostMapping(/report) public Result report(RequestBody ReportRequest request) { // Step1: 先寫DB保證強(qiáng)一致性 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: 發(fā)送MQ異步更新庫存最終一致性 mqProducer.send(stock_update_topic, new StockUpdateMessage(request.getMaterialId(), -request.getQuantity())); return Result.success(); }避坑點(diǎn)calcProgress()方法用MyBatis-Plus的LambdaQueryWrapper聚合查詢避免N1StockUpdateMessage包含時間戳下游消費(fèi)者按時間戳排序處理解決消息亂序?qū)е碌膸齑娉邸?. 部署與運(yùn)行從零開始的三步落地法含MySQL初始化與Tomcat調(diào)優(yōu)4.1 數(shù)據(jù)庫初始化執(zhí)行SQL腳本的隱藏依賴項(xiàng)目根目錄下sql/prod-system-init.sql不是普通建表語句它包含三個關(guān)鍵部分基礎(chǔ)表結(jié)構(gòu)bom_header、bom_item、work_order等12張表其中work_order表的status字段用TINYINT而非VARCHAR值0~5對應(yīng)枚舉序號節(jié)省存儲且索引更快初始數(shù)據(jù)插入3條測試BOMBOM-001含5層子項(xiàng)、2個測試工單避免首次訪問報404索引優(yōu)化為bom_item.parent_id、work_order.status、report_record.work_order_id添加復(fù)合索引EXPLAIN驗(yàn)證查詢走索引注意SQL腳本末尾有SET FOREIGN_KEY_CHECKS 0;因部分表間外鍵約束在開發(fā)階段被臨時禁用如report_record表未嚴(yán)格關(guān)聯(lián)work_order方便測試數(shù)據(jù)快速插入上線前需手動補(bǔ)全外鍵并開啟檢查。4.2 Tomcat配置針對生產(chǎn)環(huán)境的5項(xiàng)關(guān)鍵修改將target/prod-system.war部署到Tomcat 8.5后必須修改conf/server.xml和bin/catalina.sh!-- conf/server.xml -- Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxThreads200 !-- 提升并發(fā)線程數(shù) -- minSpareThreads20 !-- 避免冷啟動延遲 -- acceptCount100 !-- 連接隊列長度 -- URIEncodingUTF-8 /# bin/catalina.sh 開頭添加JVM參數(shù) JAVA_OPTS-Xms512m -Xmx1024m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -Dfile.encodingUTF-8 \ -Dspring.profiles.activeprod血淚經(jīng)驗(yàn)?zāi)晨蛻舢a(chǎn)線用低配服務(wù)器2核4G未調(diào)maxThreads時掃碼報工高峰期100并發(fā)出現(xiàn)大量Connection reset錯誤調(diào)至200后穩(wěn)定在RT800ms。4.3 啟動驗(yàn)證清單5個必測接口確認(rèn)系統(tǒng)健康部署后立即用curl或Postman驗(yàn)證以下接口返回200且數(shù)據(jù)非空即成功接口方法用途預(yù)期響應(yīng)/api/bom/tree?bomId1GET獲取BOM樹形結(jié)構(gòu){root:{id:1,name:主產(chǎn)品A,children:[...]}}/api/workorder/list?statusISSUEDGET查詢已下發(fā)工單{records:[{id:1001,code:WO-2024-001,status:2}]}/api/reportPOST提交報工記錄{code:200,msg:報工成功}/api/report/progress?workOrderId1001GET查詢工單進(jìn)度{progress:65,unit:%}/api/report/export?dateFrom2024-01-01GET導(dǎo)出日報表HTTP響應(yīng)頭含Content-Disposition: attachment; filenamereport-20240101.xlsx提示/api/report/export接口用Apache POI生成Excel大數(shù)據(jù)量時1萬行會觸發(fā)StreamingWorkbook防止OOM測試時可用dateFrom2024-01-01dateTo2024-01-02限定小范圍。5. 常見問題排查產(chǎn)線現(xiàn)場踩過的7個坑及解決方案5.1 現(xiàn)象BOM樹形頁面加載空白瀏覽器控制臺報TypeError: Cannot read property children of undefined原因前端bom-tree.js中buildTree()函數(shù)假設(shè)后端返回的BOM數(shù)據(jù)一定有children字段但當(dāng)某BOM無子項(xiàng)時后端返回{id:1,name:A,children:null}而Vue的v-for遍歷null報錯。解決在BomManage.vue的mounted()鉤子中增加防御性處理// src/pages/BomManage.vue mounted() { this.fetchBomTree().then(data { // 修復(fù)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 現(xiàn)象工單狀態(tài)從ISSUED變?yōu)镮N_PROGRESS后庫存未扣減且后臺日志無MQ發(fā)送記錄原因IssuedHandler.afterTransition()方法中調(diào)用mqProducer.send()但MQ服務(wù)未啟動且代碼未捕獲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發(fā)送失敗降級為DB更新庫存, e); // 降級直接更新庫存表 stockMapper.decreaseStock(order.getMaterialId(), order.getQuantity()); } }5.3 現(xiàn)象掃描槍掃報工碼接口返回400 Bad Request日志顯示JSON parse error: Cannot deserialize instance of java.lang.Long out of VALUE_STRING token原因掃描槍輸出的工單ID帶前導(dǎo)零如001001后端DTO用Long workOrderId接收J(rèn)ackson反序列化失敗。解決修改ReportRequest.java用String接收再轉(zhuǎn)換public class ReportRequest { private String workOrderIdStr; // 接收字符串 private Integer quantity; // 新增getter自動轉(zhuǎn)Long public Long getWorkOrderId() { return Long.valueOf(workOrderIdStr); // 生產(chǎn)環(huán)境需加try-catch } }5.4 現(xiàn)象導(dǎo)出日報表時Tomcat進(jìn)程CPU飆升100%持續(xù)5分鐘以上原因ReportExportController.export()方法用XSSFWorkbook內(nèi)存版POI生成Excel大數(shù)據(jù)量時占用堆內(nèi)存過大觸發(fā)Full GC。解決強(qiáng)制切換為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(); // 必須調(diào)用釋放臨時文件 }5.5 現(xiàn)象同一工單被兩個工人同時掃碼報工庫存扣減出現(xiàn)負(fù)數(shù)原因stockMapper.decreaseStock()方法是純SQL更新未加WHERE stock #{quantity}條件導(dǎo)致超扣。解決修改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(庫存不足當(dāng)前庫存 stockMapper.selectById(materialId).getStock()); }6. 二次開發(fā)實(shí)戰(zhàn)給報工模塊加“拍照留證”功能含前后端聯(lián)調(diào)要點(diǎn)6.1 需求場景與技術(shù)選型某客戶要求工人報工時必須上傳現(xiàn)場照片照片需綁定到報工記錄且支持在工單詳情頁查看。這不是簡單加個文件上傳要考慮三點(diǎn)1照片存哪里本地磁盤 or 對象存儲2如何防重復(fù)上傳同一工單多次報工3前端怎么拍H5調(diào)用攝像頭 or 調(diào)用APP原生API。我們選擇最穩(wěn)妥的路徑后端存本地upload/目錄前端用input typefile acceptimage/*兼容所有安卓平板瀏覽器。6.2 后端改造新增文件上傳接口與報工實(shí)體擴(kuò)展第一步在pom.xml添加文件上傳依賴項(xiàng)目原未引入dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.5/version /dependency第二步新增ReportUploadController.javaRestController 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. 校驗(yàn)文件類型 String contentType file.getContentType(); if (!contentType.startsWith(image/)) { return Result.fail(僅支持圖片文件); } // 2. 生成唯一文件名reportId 時間戳 隨機(jī)數(shù) String ext Objects.requireNonNull(file.getOriginalFilename()).substring( file.getOriginalFilename().lastIndexOf(.)); String fileName reportId _ System.currentTimeMillis() _ new Random().nextInt(1000) ext; // 3. 保存到upload/目錄需提前創(chuàng)建 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()); } } }關(guān)鍵點(diǎn)upload/目錄需在Tomcat的webapps/ROOT/下手動創(chuàng)建否則file.transferTo()拋FileNotFoundExceptionphotoUrl存相對路徑前端用img :srcreport.photoUrl即可顯示。6.3 前端聯(lián)調(diào)在報工頁面集成拍照與預(yù)覽修改pages/WorkOrderList.vue在報工彈窗中加入!-- 報工彈窗內(nèi) -- el-form-item label現(xiàn)場照片 input typefile acceptimage/* changehandlePhotoUpload refphotoInput styledisplay:none el-button click$refs.photoInput.click()拍照/選圖/el-button div v-ifpreviewUrl classphoto-preview img :srcpreviewUrl stylemax-width:200px;max-height:150px; /div /el-form-item// methods handlePhotoUpload(e) { const file e.target.files[0]; if (!file) return; // 生成預(yù)覽URL前端即時顯示 const reader new FileReader(); reader.onload (e) { this.previewUrl e.target.result; }; reader.readAsDataURL(file); // 構(gòu)造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()并設(shè)置headers: {Content-Type: multipart/form-data}。6.4 驗(yàn)證與上線 checklist完成開發(fā)后必須驗(yàn)證以下5點(diǎn)功能驗(yàn)證掃碼打開報工頁 → 點(diǎn)擊“拍照/選圖” → 選擇圖片 → 彈窗顯示預(yù)覽 → 點(diǎn)擊提交 → 后端upload/目錄生成文件 → 工單詳情頁圖片可正常顯示并發(fā)驗(yàn)證用JMeter模擬10個工人同時報工上傳檢查upload/目錄文件名是否唯一無覆蓋、數(shù)據(jù)庫photo_url字段是否正確更新清理驗(yàn)證手動刪除upload/下某張照片刷新工單詳情頁確認(rèn)圖片404且頁面不崩潰前端加error事件處理安全驗(yàn)證上傳.jsp文件確認(rèn)后端contentType校驗(yàn)攔截返回400且文件未寫入磁盤性能驗(yàn)證上傳5MB高清圖確認(rèn)上傳時間10秒Tomcat默認(rèn)maxPostSize為2MB需在server.xml中改為maxPostSize10485760從那以后我每次給產(chǎn)線系統(tǒng)加新功能都強(qiáng)制走一遍“本地模擬產(chǎn)線環(huán)境→用真實(shí)掃描槍測試→壓測并發(fā)→檢查日志埋點(diǎn)”四步流程哪怕只是加個按鈕。因?yàn)楫a(chǎn)線沒有“開發(fā)環(huán)境”和“生產(chǎn)環(huán)境”的區(qū)別只有“能用”和“不能用”——而“不能用”的代價是整條產(chǎn)線停擺一小時。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取