M-BOM, 구매리스트 엑셀 업로드 - 기준정보/숫자 형식 검증 및 저장 버튼 차단

- 코드 컬럼(가공업체/공급업체/환종/자급사급/소재재질) 기준정보 매칭 검증
- 숫자 컬럼(제작수량) 형식 검증, 미매칭/형식오류 시 알람 + 저장 버튼 숨김
- M-BOM 규격/소재품번은 PART_MNG 마스터 서버 검증 API 신설
  (/productionplanning/validateMbomMaterial.do, NFC 정규화, 디버깅 힌트 포함)
- 알람을 html 모드로 변경하여 행번호 정렬 + 줄바꿈 + hint 작은 글자 표시
- 미매칭 데이터는 그리드에 머지 반영하되 저장 버튼만 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 17:58:56 +09:00
parent 02415fcd1c
commit adfaf2de5e
5 changed files with 419 additions and 31 deletions

View File

@@ -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<String, Object> 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<String, Object> 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<String> set, int maxCount) {
if (set == null || set.isEmpty()) return "(없음)";
java.util.List<String> list = new ArrayList<String>(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<String, Object> validateMbomMaterial(Map<String, Object> paramMap) {
Map<String, Object> resultMap = new HashMap<String, Object>();
List<Map<String, Object>> invalid = new ArrayList<Map<String, Object>>();
SqlSession sqlSession = null;
try {
sqlSession = SqlMapConfig.getInstance().getSqlSession();
List<Map> masters = sqlSession.selectList("productionplanning.selectMaterialMasterAll");
Map<String, java.util.Set<String>> sizesByMaterial = new HashMap<String, java.util.Set<String>>();
Map<String, String> partNoByMaterialSpec = new HashMap<String, String>();
java.util.Set<String> validPartNos = new java.util.HashSet<String>();
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<String> sizes = sizesByMaterial.get(material);
if (sizes == null) {
sizes = new java.util.HashSet<String>();
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<String> sizes = sizesByMaterial.get(material);
if (sizes == null || !sizes.contains(spec)) {
Map<String, Object> err = new HashMap<String, Object>();
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<String, Object> err = new HashMap<String, Object>();
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<String, Object> err = new HashMap<String, Object>();
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;
}
}