diff --git a/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp b/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp index 6a790ae..668899a 100644 --- a/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp +++ b/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp @@ -1647,17 +1647,70 @@ function fn_buildHeaderFieldMap() { return map; } -// Select2 코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +// Select2/코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +// 반환: { value, isCodeColumn, matched } +// isCodeColumn=true && matched=false 이면 기준정보에 없는 값 (호출부에서 invalid 처리) function fn_reverseSelectValue(field, value) { - if(value === null || value === undefined || value === '') return value; + if(value === null || value === undefined || value === '') { + return { value: value, isCodeColumn: false, matched: true }; + } + // RAW_MATERIAL: materialList는 [string, ...] 형태(ID=text 동일) + if(field === 'RAW_MATERIAL') { + var v = String(value); + var arr = materialList || []; + for(var i = 0; i < arr.length; i++) { + if(String(arr[i]) === v) { + return { value: arr[i], isCodeColumn: true, matched: true }; + } + } + return { value: value, isCodeColumn: true, matched: false }; + } var list = null; if(field === 'PROCESSING_VENDOR') list = supplyVendorList; else if(field === 'CURRENCY') list = currencyList; - if(!list || list.length === 0) return value; - for(var i = 0; i < list.length; i++) { - if(list[i].text === value || String(list[i].id) === String(value)) return list[i].id; + else if(field === 'SUPPLY_TYPE') list = [{id:'자급', text:'자급'}, {id:'사급', text:'사급'}]; + if(!list || list.length === 0) { + return { value: value, isCodeColumn: false, matched: true }; } - return value; + for(var i = 0; i < list.length; i++) { + if(list[i].text === value || String(list[i].id) === String(value)) { + return { value: list[i].id, isCodeColumn: true, matched: true }; + } + } + return { value: value, isCodeColumn: true, matched: false }; +} + +// 필드명 → 한국어 라벨 매핑 (알람 메시지용) +function fn_getFieldLabel(field) { + if(field === 'PROCESSING_VENDOR') return '가공업체'; + if(field === 'CURRENCY') return '환종'; + if(field === 'SUPPLY_TYPE') return '자급/사급'; + if(field === 'RAW_MATERIAL') return '소재재질'; + if(field === 'SIZE') return '규격'; + if(field === 'RAW_MATERIAL_NO') return '소재품번'; + if(field === 'PRODUCTION_QTY') return '제작수량'; + return field; +} + +// 숫자만 허용 컬럼 (엑셀 업로드 시 형식 검증) +var NUMERIC_FIELDS = { PRODUCTION_QTY: true }; + +// 저장 버튼 표시/숨김 — 저장 버튼은 상위 frameset의 헤더 프레임에 위치 +// 프레임 구조: top = mBomPopupHeaderFs → top.frames[0] = mBomHeaderPopup (#btnSave 보유) +function fn_toggleSaveBtn(show) { + try { + var headerFrame = (top && top.frames && top.frames[0]) ? top.frames[0] : null; + if(!headerFrame || !headerFrame.document) return; + var btn = headerFrame.document.getElementById('btnSave'); + if(!btn) return; + btn.style.display = show ? '' : 'none'; + } catch(e) {} +} + +// SweetAlert html 모드에서 사용자 입력값이 HTML로 해석되지 않도록 이스케이프 +function fn_escapeHtml(s) { + if(s === null || s === undefined) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // 엑셀 업로드: 파싱 → 역매핑 → OBJID 무결성 검증 → PART_NO 우선 매칭(중복 시 OBJID) → 그리드 주입 @@ -1691,6 +1744,7 @@ function fn_excelUpload(file) { return; } var headerFieldMap = fn_buildHeaderFieldMap(); + var editableFields = fn_buildEditableFields(); // 기존 행: PART_NO별 그룹 + OBJID 화이트리스트 var existing = _tabulGrid.getData() || []; @@ -1704,8 +1758,9 @@ function fn_excelUpload(file) { if(oid) existingObjidSet[oid] = true; } - // 1) 헤더 → field 변환 + Select2 역매핑 + // 1) 헤더 → field 변환 + Select2 역매핑 + 기준정보 미매칭/숫자 형식 오류 수집 (편집 가능 컬럼만) var converted = []; + var invalidCells = []; // { row, field, label, value, reason } for(var i = 0; i < rows.length; i++) { var src = rows[i]; var out = {}; @@ -1713,7 +1768,34 @@ function fn_excelUpload(file) { if(!src.hasOwnProperty(header)) continue; var field = headerFieldMap[header]; if(!field) continue; - out[field] = fn_reverseSelectValue(field, src[header]); + var rawVal = src[header]; + // 숫자 컬럼: 빈 값 통과, 숫자 변환 실패 시 invalid (편집 가능 컬럼만) + if(NUMERIC_FIELDS[field] && editableFields[field]) { + if(rawVal !== null && rawVal !== undefined && String(rawVal).trim() !== '') { + var numVal = Number(String(rawVal).replace(/,/g, '')); + if(isNaN(numVal)) { + invalidCells.push({ + row: i + 2, field: field, label: fn_getFieldLabel(field), + value: rawVal, reason: '숫자만 입력 가능합니다' + }); + out[field] = rawVal; // 그리드에 그대로 반영(사용자가 보고 수정) + } else { + out[field] = numVal; + } + } else { + out[field] = rawVal; + } + continue; + } + // 코드 컬럼: 역매핑 + 기준정보 매칭 검증 + var r = fn_reverseSelectValue(field, rawVal); + out[field] = r.value; + if(r.isCodeColumn && !r.matched && editableFields[field]) { + invalidCells.push({ + row: i + 2, field: field, label: fn_getFieldLabel(field), + value: rawVal, reason: '기준정보에 없습니다' + }); + } } converted.push(out); } @@ -1736,9 +1818,6 @@ function fn_excelUpload(file) { return; } - // 편집 가능 컬럼 집합 (이것만 머지 시 덮어쓰기, 그 외는 base 보존) - var editableFields = fn_buildEditableFields(); - // 3) PART_NO 우선 매칭 (중복 시 OBJID disambiguate) var usedObjids = {}; function pickMatch(uploadRow) { @@ -1821,8 +1900,79 @@ function fn_excelUpload(file) { return; } - _tabulGrid.replaceData(merged).then(function() { - Swal.fire('완료', merged.length + '건이 그리드에 적용되었습니다. 저장 버튼을 눌러 반영하세요.', 'success'); + // finalize: 그리드 반영 + 알람 + 저장 버튼 토글 (서버 검증 후 호출) + function fn_finalizeUpload() { + if(invalidCells.length > 0) { + _tabulGrid.replaceData(merged).then(function() { + fn_toggleSaveBtn(false); + // 행 번호 오름차순으로 정렬 후 메시지 구성 + invalidCells.sort(function(a, b) { return (a.row || 0) - (b.row || 0); }); + var lines = ''; + for(var i = 0; i < Math.min(invalidCells.length, 20); i++) { + var it = invalidCells[i]; + lines += ' - ' + it.row + '행: ' + fn_escapeHtml(it.label) + + " '" + fn_escapeHtml(it.value) + "' " + + fn_escapeHtml(it.reason); + if(it.hint) { + lines += '
↳ ' + + fn_escapeHtml(it.hint) + '
'; + } else { + lines += '
'; + } + } + if(invalidCells.length > 20) lines += ' ... (총 ' + invalidCells.length + '건)
'; + var html = '잘못된 값이 포함되어 저장이 차단됩니다.
' + + '정상 엑셀로 다시 업로드하세요.

' + + '
' + lines + '
'; + Swal.fire({ title: '오류', html: html, icon: 'error', width: 700 }); + }); + } else { + _tabulGrid.replaceData(merged).then(function() { + fn_toggleSaveBtn(true); + Swal.fire('완료', merged.length + '건이 그리드에 적용되었습니다. 저장 버튼을 눌러 반영하세요.', 'success'); + }); + } + } + + // 5) 서버 검증: 소재재질+규격+소재품번 (PART_MNG 마스터 매칭) + var serverRows = []; + for(var i = 0; i < merged.length; i++) { + var r = merged[i]; + serverRows.push({ + row: i + 2, + SUPPLY_TYPE: r.SUPPLY_TYPE || '', + PART_NO: r.PART_NO || '', + RAW_MATERIAL: r.RAW_MATERIAL || '', + SIZE: r.SIZE || '', + RAW_MATERIAL_NO: r.RAW_MATERIAL_NO || '' + }); + } + $.ajax({ + url: '/productionplanning/validateMbomMaterial.do', + method: 'POST', + contentType: 'application/json; charset=UTF-8', + data: JSON.stringify({ rows: serverRows }), + dataType: 'json', + success: function(res) { + if(res && res.resultFlag === 'S' && res.invalid && res.invalid.length > 0) { + for(var i = 0; i < res.invalid.length; i++) { + var inv = res.invalid[i]; + invalidCells.push({ + row: inv.row, + field: inv.field, + label: fn_getFieldLabel(inv.field), + value: inv.value, + reason: inv.reason, + hint: inv.hint || '' + }); + } + } + fn_finalizeUpload(); + }, + error: function() { + Swal.fire('경고', '소재 마스터 검증 호출에 실패했습니다. 클라이언트 검증만 적용됩니다.', 'warning'); + fn_finalizeUpload(); + } }); }, error: function(xhr, status, error) { diff --git a/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp b/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp index fda3a9a..bbcd5bf 100644 --- a/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp +++ b/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp @@ -109,7 +109,7 @@ body, html { - +
@@ -1431,16 +1431,59 @@ function fn_buildHeaderFieldMap() { } // Select2 코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +// Select2/정적 코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +// 반환: { value, isCodeColumn, matched } +// isCodeColumn=true && matched=false 이면 기준정보에 없는 값 (호출부에서 invalid 처리) +// 화이트리스트에 있는 필드는 list가 비어 있어도 isCodeColumn=true로 간주(검증 누락 방지) function fn_reverseSelectValue(field, value) { - if(value === null || value === undefined || value === '') return value; - var list = null; - if(field === 'VENDOR_PM' || field === 'PROCESSING_VENDOR') list = processingVendorList; - else if(field === 'CURRENCY') list = currencyList; - if(!list || list.length === 0) return value; - for(var i = 0; i < list.length; i++) { - if(list[i].text === value || String(list[i].id) === String(value)) return list[i].id; + if(value === null || value === undefined || value === '') { + return { value: value, isCodeColumn: false, matched: true }; } - return value; + var list = null; + var isCode = false; + if(field === 'VENDOR_PM' || field === 'PROCESSING_VENDOR') { list = processingVendorList; isCode = true; } + else if(field === 'CURRENCY') { list = currencyList; isCode = true; } + else if(field === 'USE_YN') { list = [{id:'사용', text:'사용'}, {id:'미사용', text:'미사용'}]; isCode = true; } + if(!isCode) { + return { value: value, isCodeColumn: false, matched: true }; + } + if(!list) list = []; + var v = String(value).trim(); + for(var i = 0; i < list.length; i++) { + if(String(list[i].text).trim() === v || String(list[i].id).trim() === v) { + return { value: list[i].id, isCodeColumn: true, matched: true }; + } + } + return { value: value, isCodeColumn: true, matched: false }; +} + +// 필드명 → 한국어 라벨 매핑 (알람 메시지용) +function fn_getFieldLabel(field) { + if(field === 'VENDOR_PM') return '공급업체'; + if(field === 'PROCESSING_VENDOR') return '가공업체'; + if(field === 'CURRENCY') return '환종'; + if(field === 'USE_YN') return '사용여부'; + if(field === 'UNIT_PRICE') return '소재단가'; + if(field === 'PO_QTY') return '발주수량'; + if(field === 'PROCESSING_UNIT_PRICE') return '가공단가'; + return field; +} + +// 숫자만 허용 컬럼 (엑셀 업로드 시 형식 검증). 천단위 콤마는 허용. +var NUMERIC_FIELDS = { UNIT_PRICE: true, PO_QTY: true, PROCESSING_UNIT_PRICE: true }; + +// 저장 버튼 표시/숨김 (엑셀 업로드에서 기준정보 미매칭 발생 시 저장 차단) +function fn_toggleSaveBtn(show) { + var $btn = $('#btnSave'); + if(!$btn.length) return; + if(show) $btn.show(); + else $btn.hide(); +} + +// SweetAlert html 모드에서 사용자 입력값이 HTML로 해석되지 않도록 이스케이프 +function fn_escapeHtml(s) { + if(s === null || s === undefined) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // 엑셀 업로드: 파싱 → 역매핑 → OBJID 무결성 검증 → PART_NO 우선 매칭(중복 시 OBJID) → 그리드 주입 @@ -1468,6 +1511,7 @@ function fn_excelUpload(file) { return; } var headerFieldMap = fn_buildHeaderFieldMap(); + var editableFields = fn_buildEditableFields(); // 기존 행: PART_NO별 그룹 + OBJID 화이트리스트 var existing = _tabulGrid.getData() || []; @@ -1481,8 +1525,9 @@ function fn_excelUpload(file) { if(oid) existingObjidSet[oid] = true; } - // 1) 헤더 → field 변환 + Select2 역매핑 + // 1) 헤더 → field 변환 + Select2 역매핑 + 기준정보 미매칭/숫자 형식 오류 수집 (편집 가능 컬럼만) var converted = []; + var invalidCells = []; // { row, field, label, value, reason } for(var i = 0; i < rows.length; i++) { var src = rows[i]; var out = {}; @@ -1490,7 +1535,36 @@ function fn_excelUpload(file) { if(!src.hasOwnProperty(header)) continue; var field = headerFieldMap[header]; if(!field) continue; - out[field] = fn_reverseSelectValue(field, src[header]); + var rawVal = src[header]; + // 숫자 컬럼: 빈 값 통과, 변환 실패 시 invalid + if(NUMERIC_FIELDS[field] && editableFields[field]) { + if(rawVal !== null && rawVal !== undefined && String(rawVal).trim() !== '') { + var numVal = Number(String(rawVal).replace(/,/g, '')); + if(isNaN(numVal)) { + invalidCells.push({ + row: i + 2, field: field, label: fn_getFieldLabel(field), + value: rawVal, reason: '숫자만 입력 가능합니다' + }); + out[field] = rawVal; + } else { + out[field] = numVal; + } + } else { + out[field] = rawVal; + } + continue; + } + // 코드 컬럼: 역매핑 + 기준정보 매칭 검증 + // (editable 체크 없이 검증 — 코드 컬럼은 모두 사용자 입력 대상이므로 + // editableFields 판정 누락 시에도 안전하게 잡기 위함) + var r = fn_reverseSelectValue(field, rawVal); + out[field] = r.value; + if(r.isCodeColumn && !r.matched) { + invalidCells.push({ + row: i + 2, field: field, label: fn_getFieldLabel(field), + value: rawVal, reason: '기준정보에 없습니다' + }); + } } converted.push(out); } @@ -1513,9 +1587,6 @@ function fn_excelUpload(file) { return; } - // 편집 가능 컬럼 집합 (이것만 머지 시 덮어쓰기, 그 외는 base 보존) - var editableFields = fn_buildEditableFields(); - // 3) PART_NO 우선 매칭 (중복 시 OBJID disambiguate) var usedObjids = {}; function pickMatch(uploadRow) { @@ -1596,7 +1667,29 @@ function fn_excelUpload(file) { return; } + // 5) 잘못된 셀(기준정보 미매칭/숫자 형식 오류) 분기: 데이터는 그리드에 반영하되 저장 버튼 숨김 + if(invalidCells.length > 0) { + _tabulGrid.replaceData(merged).then(function() { + fn_toggleSaveBtn(false); + invalidCells.sort(function(a, b) { return (a.row || 0) - (b.row || 0); }); + var lines = ''; + for(var i = 0; i < Math.min(invalidCells.length, 20); i++) { + var it = invalidCells[i]; + lines += ' - ' + it.row + '행: ' + fn_escapeHtml(it.label) + + " '" + fn_escapeHtml(it.value) + "' " + + fn_escapeHtml(it.reason) + '
'; + } + if(invalidCells.length > 20) lines += ' ... (총 ' + invalidCells.length + '건)
'; + var html = '잘못된 값이 포함되어 저장이 차단됩니다.
' + + '정상 엑셀로 다시 업로드하세요.

' + + '
' + lines + '
'; + Swal.fire({ title: '오류', html: html, icon: 'error', width: 700 }); + }); + return; + } + _tabulGrid.replaceData(merged).then(function() { + fn_toggleSaveBtn(true); Swal.fire('완료', merged.length + '건이 그리드에 적용되었습니다. 저장 버튼을 눌러 반영하세요.', 'success'); }); }, diff --git a/src/com/pms/controller/ProductionPlanningController.java b/src/com/pms/controller/ProductionPlanningController.java index 698d61f..52f26f3 100644 --- a/src/com/pms/controller/ProductionPlanningController.java +++ b/src/com/pms/controller/ProductionPlanningController.java @@ -2228,4 +2228,14 @@ public class ProductionPlanningController extends BaseService { return "/productionplanning/mBomHistoryDetailPopup"; } + /** + * M-BOM 엑셀 업로드 - 소재 마스터 검증 (소재재질/규격/소재품번) + * 클라이언트가 머지된 행 배열을 JSON 본문으로 전달, 서버는 invalid 배열 반환. + */ + @ResponseBody + @RequestMapping("/productionplanning/validateMbomMaterial.do") + public Map validateMbomMaterial(@RequestBody Map paramMap) { + return productionPlanningService.validateMbomMaterial(paramMap); + } + } diff --git a/src/com/pms/mapper/productionplanning.xml b/src/com/pms/mapper/productionplanning.xml index a50b731..49e9a2a 100644 --- a/src/com/pms/mapper/productionplanning.xml +++ b/src/com/pms/mapper/productionplanning.xml @@ -5496,5 +5496,16 @@ WHERE ITEM_PARENT_CD IS NOT NULL AND ITEM_PARENT_CD != '' + + + diff --git a/src/com/pms/service/ProductionPlanningService.java b/src/com/pms/service/ProductionPlanningService.java index 846fe6e..8012583 100644 --- a/src/com/pms/service/ProductionPlanningService.java +++ b/src/com/pms/service/ProductionPlanningService.java @@ -10,6 +10,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import org.apache.ibatis.session.SqlSession; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.pms.common.JsonUtil; @@ -22,10 +23,10 @@ import com.pms.common.utils.SerialNoSyncUtil; @Service public class ProductionPlanningService { - - - - + + @Autowired + private BatchService batchService; + /** * 이슈관리 상세 조회 * @param paramMap @@ -1191,12 +1192,11 @@ public class ProductionPlanningService { public boolean saveMbom(HttpServletRequest request, Map paramMap) { SqlSession sqlSession = null; boolean result = false; + String mbomHeaderObjidForErp = null; try { - // 트랜잭션 처리를 위해 autoCommit = false로 설정 sqlSession = SqlMapConfig.getInstance().getSqlSession(false); - // 세션에서 사용자 정보 가져오기 HttpSession session = request.getSession(); PersonBean person = (PersonBean)session.getAttribute(Constants.PERSON_BEAN); String userId = CommonUtils.checkNull(person.getUserId()); @@ -1362,6 +1362,7 @@ public class ProductionPlanningService { } // M-BOM 헤더 업데이트 + mbomHeaderObjidForErp = CommonUtils.checkNull(existingMbom.get("OBJID")); paramMap.put("mbomHeaderObjid", existingMbom.get("OBJID")); sqlSession.update("productionplanning.updateMbomHeader", paramMap); @@ -1497,6 +1498,7 @@ public class ProductionPlanningService { } // MBOM_HEADER 삽입 + mbomHeaderObjidForErp = newObjid; sqlSession.insert("productionplanning.insertMbomHeader", paramMap); // MBOM_DETAIL 삽입 @@ -1586,7 +1588,27 @@ public class ProductionPlanningService { sqlSession.close(); } } - + + // DB 커밋 성공 후 ERP BOM 동기화 (ERP 실패해도 DB는 유지) + if(result && mbomHeaderObjidForErp != null && !mbomHeaderObjidForErp.isEmpty()) { + try { + System.out.println("===================================="); + System.out.println("M-BOM 저장 후 ERP BOM 동기화 시작"); + System.out.println("MBOM_HEADER_OBJID: " + mbomHeaderObjidForErp); + System.out.println("===================================="); + + Map erpResult = batchService.syncMbomBomToErp(mbomHeaderObjidForErp); + if(erpResult != null && Boolean.TRUE.equals(erpResult.get("success"))) { + System.out.println("ERP BOM 동기화 성공: " + erpResult.get("message")); + } else { + System.err.println("ERP BOM 동기화 실패: " + (erpResult != null ? erpResult.get("message") : "결과 없음")); + } + } catch(Exception erpEx) { + System.err.println("ERP BOM 동기화 오류: " + erpEx.getMessage()); + erpEx.printStackTrace(); + } + } + return result; } @@ -2456,4 +2478,150 @@ public class ProductionPlanningService { } return resultMap; } + + /** + * 키 비교용 정규화: 양끝 공백 제거 + 유니코드 NFC 정규화. + * 'Ø', 'Å' 등 결합문자가 NFC/NFD 형태로 저장돼 표면 글자는 같아도 매칭 실패하는 경우 방지. + */ + private String normalizeKey(Object obj) { + String s = CommonUtils.checkNull(obj); + if (s.isEmpty()) return s; + s = s.trim(); + try { + s = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFC); + } catch (Exception ignore) {} + return s; + } + + // 디버깅 힌트용: Set의 일부 샘플을 ", "로 연결 (정렬, 최대 maxCount개) + private String joinSample(java.util.Set set, int maxCount) { + if (set == null || set.isEmpty()) return "(없음)"; + java.util.List list = new ArrayList(set); + java.util.Collections.sort(list); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < list.size() && i < maxCount; i++) { + if (i > 0) sb.append(", "); + sb.append("'").append(list.get(i)).append("'"); + } + if (list.size() > maxCount) sb.append(" ... (총 ").append(list.size()).append("개)"); + return sb.toString(); + } + + /** + * M-BOM 엑셀 업로드 - 소재 마스터(소재재질+규격+소재품번) 검증. + * 입력 paramMap: { rows: [ { row, SUPPLY_TYPE, PART_NO, RAW_MATERIAL, SIZE, RAW_MATERIAL_NO }, ... ] } + * 출력: { resultFlag: 'S', invalid: [ { row, field, value, reason }, ... ] } + * + * 검증 규칙 (사급 행만 — 자급은 소재 무관): + * - SIZE: (RAW_MATERIAL, SIZE) 조합이 마스터에 존재해야 함 + * - RAW_MATERIAL_NO: + * - (RAW_MATERIAL, SIZE) 모두 있으면 그 조합으로 매핑된 소재품번과 일치해야 함 + * - 그 외에는 마스터의 소재품번 집합에 존재해야 함 + */ + public Map validateMbomMaterial(Map paramMap) { + Map resultMap = new HashMap(); + List> invalid = new ArrayList>(); + SqlSession sqlSession = null; + try { + sqlSession = SqlMapConfig.getInstance().getSqlSession(); + + List masters = sqlSession.selectList("productionplanning.selectMaterialMasterAll"); + Map> sizesByMaterial = new HashMap>(); + Map partNoByMaterialSpec = new HashMap(); + java.util.Set validPartNos = new java.util.HashSet(); + for (int i = 0; i < masters.size(); i++) { + Map m = masters.get(i); + String material = normalizeKey(m.get("MATERIAL_CODE")); + String spec = normalizeKey(m.get("SIZE_SPEC")); + String partNo = normalizeKey(m.get("MATERIAL_PART_NO")); + java.util.Set sizes = sizesByMaterial.get(material); + if (sizes == null) { + sizes = new java.util.HashSet(); + sizesByMaterial.put(material, sizes); + } + if (!spec.isEmpty()) sizes.add(spec); + if (!material.isEmpty() && !spec.isEmpty()) { + partNoByMaterialSpec.put(material + "|" + spec, partNo); + } + if (!partNo.isEmpty()) validPartNos.add(partNo); + } + + Object rowsObj = paramMap.get("rows"); + if (rowsObj instanceof List) { + List rows = (List) rowsObj; + for (int i = 0; i < rows.size(); i++) { + Object rowObj = rows.get(i); + if (!(rowObj instanceof Map)) continue; + Map row = (Map) rowObj; + + Object rowNoObj = row.get("row"); + int rowNo; + try { + rowNo = (rowNoObj == null) ? (i + 2) : Integer.parseInt(String.valueOf(rowNoObj)); + } catch (Exception ignore) { + rowNo = i + 2; + } + + String supplyType = CommonUtils.checkNull(row.get("SUPPLY_TYPE")).trim(); + if ("자급".equals(supplyType)) continue; + + String material = normalizeKey(row.get("RAW_MATERIAL")); + String spec = normalizeKey(row.get("SIZE")); + String partNo = normalizeKey(row.get("RAW_MATERIAL_NO")); + + // SIZE 검증 + if (!material.isEmpty() && !spec.isEmpty()) { + java.util.Set sizes = sizesByMaterial.get(material); + if (sizes == null || !sizes.contains(spec)) { + Map err = new HashMap(); + err.put("row", rowNo); + err.put("field", "SIZE"); + err.put("value", spec); + err.put("reason", "기준정보에 없는 규격입니다"); + // 디버깅 힌트: 같은 소재재질에 어떤 규격들이 있는지 일부 노출 + if (sizes == null) { + err.put("hint", "PART_MNG에 소재재질 '" + material + "' 자체가 없습니다 (또는 STATUS!='release' / ACCTFG!='0')"); + } else { + err.put("hint", "사용 가능 규격: " + joinSample(sizes, 8)); + } + invalid.add(err); + } + } + + // RAW_MATERIAL_NO 검증 + if (!partNo.isEmpty()) { + if (!material.isEmpty() && !spec.isEmpty()) { + String expected = partNoByMaterialSpec.get(material + "|" + spec); + if (expected != null && !expected.equals(partNo)) { + Map err = new HashMap(); + err.put("row", rowNo); + err.put("field", "RAW_MATERIAL_NO"); + err.put("value", partNo); + err.put("reason", "(소재재질+규격) 조합과 일치하지 않습니다"); + err.put("hint", "마스터 매핑값: '" + expected + "'"); + invalid.add(err); + } + } else if (!validPartNos.contains(partNo)) { + Map err = new HashMap(); + err.put("row", rowNo); + err.put("field", "RAW_MATERIAL_NO"); + err.put("value", partNo); + err.put("reason", "기준정보에 없는 소재품번입니다"); + invalid.add(err); + } + } + } + } + + resultMap.put("resultFlag", "S"); + resultMap.put("invalid", invalid); + } catch (Exception e) { + e.printStackTrace(); + resultMap.put("resultFlag", "E"); + resultMap.put("message", e.getMessage()); + } finally { + if (sqlSession != null) sqlSession.close(); + } + return resultMap; + } }