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