From d70e9abd45e7371b0c1228ecd4b4d30064683bbc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 23 Apr 2026 18:45:45 +0900 Subject: [PATCH] =?UTF-8?q?M-BOM,=20=EA=B5=AC=EB=A7=A4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=9D=EC=97=85=20=EC=97=91=EC=85=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 엑셀 파싱 유틸(ExcelParseUtil) + /common/parseExcelFile.do 엔드포인트 추가 (xlsx/xls/csv 지원, 임시 업로드 후 파싱·삭제) - 두 팝업에 Excel Upload 버튼 추가, PART_NO 우선 매칭(중복 시 OBJID), 신규 행 추가·기존 행 삭제·OBJID 변경 시 업로드 차단 - Excel Download를 ExcelJS 기반 xlsx로 교체: 헤더 색상(편집 가능 컬럼 노란색), Select2 코드값→코드명 변환, 날짜 셀 텍스트 강제, hidden OBJID 컬럼 포함 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../view/productionplanning/mBomPopupLeft.jsp | 350 ++++++++++++++++- .../view/salesMng/purchaseListFormPopUp.jsp | 358 +++++++++++++++++- src/com/pms/common/utils/ExcelParseUtil.java | 181 +++++++++ src/com/pms/controller/CommonController.java | 70 ++++ 4 files changed, 943 insertions(+), 16 deletions(-) create mode 100644 src/com/pms/common/utils/ExcelParseUtil.java diff --git a/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp b/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp index 5124e55..95389ee 100644 --- a/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp +++ b/WebContent/WEB-INF/view/productionplanning/mBomPopupLeft.jsp @@ -321,7 +321,16 @@ function fn_initGrid() { 'data-LEVEL="' + (rowData.LEVEL || 1) + '">'; } }); - + + // 행 식별 키 (수정 금지). 그리드에는 숨기고 다운로드에만 포함. + columns.push({ + title: 'KEY(수정금지)', + field: 'OBJID', + visible: false, + download: true, + headerSort: false + }); + // 수준 컬럼 그룹 var levelColumns = []; for(var i = 1; i <= maxLevel; i++) { @@ -703,6 +712,7 @@ function fn_initGrid() { title: '소재품번', field: 'RAW_MATERIAL_NO', editor: false, + downloadHighlighted: true, // 엑셀 다운로드 시 노란색 강조 (자동 채움 X, 사용자 입력 필요) formatter: function(cell) { var data = cell.getRow().getData(); if(data.SUPPLY_TYPE === '자급') return '-'; @@ -794,6 +804,14 @@ function fn_initGrid() { } } return value; + }, + // 다운로드 시 코드값(id) → 코드명(text) + accessorDownload: function(value) { + if(!value) return ''; + for(var i = 0; i < supplyVendorList.length; i++) { + if(supplyVendorList[i].id == value) return supplyVendorList[i].text; + } + return value; } }, { @@ -860,6 +878,14 @@ function fn_initGrid() { } } return value; + }, + // 다운로드 시 코드값(id) → 코드명(text) + accessorDownload: function(value) { + if(!value) return ''; + for(var i = 0; i < currencyList.length; i++) { + if(currencyList[i].id == value) return currencyList[i].text; + } + return value; } }, // 숨김 컬럼: 품의서 작성일 (저장 시 기존 값 유지) @@ -1455,32 +1481,330 @@ function getMbomTreeData() { return mbomData; } -// 엑셀 다운로드 (CSV 형식) +// 컬럼 정의로부터 다운로드/업로드에 사용할 헤더 텍스트 결정 (titleDownload 우선) +function fn_getHeaderText(c) { + if(c.titleDownload) return c.titleDownload; + if(c.title) return $('
').html(c.title).text().trim(); + return c.field || ''; +} + +// 엑셀 다운로드 (XLSX, ExcelJS). hidden 컬럼인 OBJID도 download:true면 포함. function fn_excel() { if(!_tabulGrid) { Swal.fire('데이터가 없습니다.'); return; } - - // 파일명 생성 (현재 날짜 포함) + + // 1) 내보낼 컬럼 수집 + var defs = _tabulGrid.getColumnDefinitions(); + var exportCols = []; + function collectCols(arr) { + for(var i = 0; i < arr.length; i++) { + var c = arr[i]; + if(c.columns) { collectCols(c.columns); continue; } + if(!c.field) continue; + if(c.download === false) continue; + if(c.download !== true && c.visible === false) continue; + exportCols.push({ + field: c.field, + header: fn_getHeaderText(c), + accessorDownload: c.accessorDownload || null, + highlighted: c.downloadHighlighted === true || (typeof c.editor === 'function' || c.editor === true) || (c.title && /background-color/.test(String(c.title))) + }); + } + } + collectCols(defs); + + // 2) Workbook + var workbook = new ExcelJS.Workbook(); + var worksheet = workbook.addWorksheet('M-BOM'); + + // 3) 헤더 + var headerRow = worksheet.addRow(exportCols.map(function(c) { return c.header; })); + headerRow.height = 22; + headerRow.eachCell(function(cell, colNumber) { + var col = exportCols[colNumber - 1]; + cell.font = { bold: true }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { + top: { style: 'thin' }, left: { style: 'thin' }, + bottom: { style: 'thin' }, right: { style: 'thin' } + }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: col.highlighted ? 'FFFFFF00' : 'FFD9E1F2' } + }; + }); + + // 4) 데이터 + var data = _tabulGrid.getData() || []; + for(var r = 0; r < data.length; r++) { + var row = data[r]; + var rowVals = exportCols.map(function(c) { + var raw = row[c.field]; + if(c.accessorDownload) { + try { raw = c.accessorDownload(raw, row, 'xlsx', {}, null); } catch(e) {} + } + return raw === null || raw === undefined ? '' : raw; + }); + var dataRow = worksheet.addRow(rowVals); + dataRow.eachCell({ includeEmpty: true }, function(cell) { + cell.border = { + top: { style: 'thin' }, left: { style: 'thin' }, + bottom: { style: 'thin' }, right: { style: 'thin' } + }; + if(typeof cell.value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(cell.value)) { + cell.numFmt = '@'; + } + }); + } + + // 5) 컬럼 폭 + exportCols.forEach(function(c, i) { + var maxLen = (c.header || '').length; + for(var r = 0; r < data.length; r++) { + var v = data[r][c.field]; + if(v != null) { + var s = String(v); + if(s.length > maxLen) maxLen = s.length; + } + } + worksheet.getColumn(i + 1).width = Math.min(Math.max(maxLen + 2, 10), 40); + }); + + // 6) 다운로드 var today = new Date(); - var dateStr = today.getFullYear() + '_' + - String(today.getMonth() + 1).padStart(2, '0') + '_' + + var dateStr = today.getFullYear() + '_' + + String(today.getMonth() + 1).padStart(2, '0') + '_' + String(today.getDate()).padStart(2, '0') + '_' + String(today.getHours()).padStart(2, '0') + '_' + String(today.getMinutes()).padStart(2, '0'); - var fileName = 'M-BOM_' + dateStr + '.csv'; - - // Tabulator 내장 다운로드 기능 사용 (CSV) - _tabulGrid.download("csv", fileName, { - delimiter: ",", - bom: true // 한글 깨짐 방지 (UTF-8 BOM) + var fileName = 'M-BOM_' + dateStr + '.xlsx'; + + workbook.xlsx.writeBuffer().then(function(buffer) { + var blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + setTimeout(function() { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + }).catch(function(err) { + console.error('Excel 다운로드 실패:', err); + Swal.fire('오류', 'Excel 다운로드 중 오류가 발생했습니다.', 'error'); }); } + +// columns 정의에서 헤더(title) → field 매핑 자동 생성 (titleDownload 우선) +function fn_buildHeaderFieldMap() { + var map = {}; + var cols = _tabulGrid.getColumnDefinitions(); + function walk(arr) { + for(var i = 0; i < arr.length; i++) { + var c = arr[i]; + if(c.columns) walk(c.columns); + if(c.field) { + var title = fn_getHeaderText(c); + if(title) map[title] = c.field; + } + } + } + walk(cols); + return map; +} + +// Select2 코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +function fn_reverseSelectValue(field, value) { + if(value === null || value === undefined || value === '') return value; + 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; + } + return value; +} + +// 엑셀 업로드: 파싱 → 역매핑 → OBJID 무결성 검증 → PART_NO 우선 매칭(중복 시 OBJID) → 그리드 주입 +// 매칭 규칙: +// - PART_NO 그룹에 후보 1개 : 그대로 매칭 (OBJID 무관) +// - PART_NO 그룹에 후보 N개 : OBJID로 정밀 매칭, 없으면 첫 미사용 후보로 fallback +// - PART_NO 매칭 실패 : 신규 행 +// 안전장치: +// - OBJID 값이 있는데 기존 그리드 OBJID 중에 없으면 업로드 중단 (수정금지 위반 방지) +function fn_excelUpload(file) { + if(!file) return; + var fd = new FormData(); + fd.append('excelFile', file); + fd.append('headerRow', '0'); + + $.ajax({ + url: '/common/parseExcelFile.do', + method: 'POST', + data: fd, + processData: false, + contentType: false, + dataType: 'json', + success: function(res) { + if(!res || res.resultFlag !== 'S') { + Swal.fire('오류', (res && res.message) || '엑셀 파싱에 실패했습니다.', 'error'); + return; + } + var rows = res.rows || []; + if(rows.length === 0) { + Swal.fire('알림', '읽어들인 행이 없습니다.', 'warning'); + return; + } + var headerFieldMap = fn_buildHeaderFieldMap(); + + // 기존 행: PART_NO별 그룹 + OBJID 화이트리스트 + var existing = _tabulGrid.getData() || []; + var existingByPartNo = {}; + var existingObjidSet = {}; + for(var i = 0; i < existing.length; i++) { + var pno = String(existing[i].PART_NO || ''); + if(!existingByPartNo[pno]) existingByPartNo[pno] = []; + existingByPartNo[pno].push(existing[i]); + var oid = String(existing[i].OBJID || ''); + if(oid) existingObjidSet[oid] = true; + } + + // 1) 헤더 → field 변환 + Select2 역매핑 + var converted = []; + for(var i = 0; i < rows.length; i++) { + var src = rows[i]; + var out = {}; + for(var header in src) { + if(!src.hasOwnProperty(header)) continue; + var field = headerFieldMap[header]; + if(!field) continue; + out[field] = fn_reverseSelectValue(field, src[header]); + } + converted.push(out); + } + + // 2) OBJID 무결성 검증 + var invalid = []; + for(var i = 0; i < converted.length; i++) { + var oid = String(converted[i].OBJID || ''); + if(oid && !existingObjidSet[oid]) { + invalid.push({ row: i + 2, objid: oid }); + } + } + if(invalid.length > 0) { + var msg = 'KEY(OBJID) 컬럼이 변경되어 업로드를 중단합니다.\n해당 셀은 수정 금지입니다.\n신규 행은 KEY 셀을 비워두세요.\n\n'; + for(var i = 0; i < Math.min(invalid.length, 5); i++) { + msg += ' - ' + invalid[i].row + '행: ' + invalid[i].objid + '\n'; + } + if(invalid.length > 5) msg += ' ... (총 ' + invalid.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + // 3) PART_NO 우선 매칭 (중복 시 OBJID disambiguate) + var usedObjids = {}; + function pickMatch(uploadRow) { + var pno = String(uploadRow.PART_NO || ''); + var oid = String(uploadRow.OBJID || ''); + var candidates = existingByPartNo[pno] || []; + if(candidates.length === 0) return null; + if(candidates.length === 1) { + var only = candidates[0]; + var k = String(only.OBJID || ''); + if(usedObjids[k]) return null; + usedObjids[k] = true; + return only; + } + // 후보 N개: OBJID 우선 + if(oid) { + for(var j = 0; j < candidates.length; j++) { + var c = candidates[j]; + var ck = String(c.OBJID || ''); + if(ck === oid && !usedObjids[ck]) { + usedObjids[ck] = true; + return c; + } + } + } + // fallback: 첫 미사용 후보 + for(var j = 0; j < candidates.length; j++) { + var c = candidates[j]; + var ck = String(c.OBJID || ''); + if(!usedObjids[ck]) { + usedObjids[ck] = true; + return c; + } + } + return null; + } + + var merged = []; + var unmatched = []; + for(var i = 0; i < converted.length; i++) { + var out = converted[i]; + var base = pickMatch(out); + if(!base) { + unmatched.push({ row: i + 2, partNo: String(out.PART_NO || '') }); + continue; + } + merged.push($.extend({}, base, out)); + } + if(unmatched.length > 0) { + var msg = '기존에 없는 행이 포함되어 업로드를 중단합니다.\n신규 행은 추가할 수 없습니다.\n\n'; + for(var i = 0; i < Math.min(unmatched.length, 5); i++) { + msg += ' - ' + unmatched[i].row + '행: PART_NO=' + (unmatched[i].partNo || '(빈값)') + '\n'; + } + if(unmatched.length > 5) msg += ' ... (총 ' + unmatched.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + // 4) 누락 검증: 매칭되지 않은 기존 행이 있으면 차단 (행 삭제 금지) + var missing = []; + for(var i = 0; i < existing.length; i++) { + var oid = String(existing[i].OBJID || ''); + if(oid && !usedObjids[oid]) { + missing.push({ partNo: String(existing[i].PART_NO || ''), objid: oid }); + } + } + if(missing.length > 0) { + var msg = '엑셀에서 행이 삭제되어 업로드를 중단합니다.\n행 삭제는 허용되지 않습니다.\n\n'; + for(var i = 0; i < Math.min(missing.length, 5); i++) { + msg += ' - PART_NO=' + (missing[i].partNo || '(빈값)') + '\n'; + } + if(missing.length > 5) msg += ' ... (총 ' + missing.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + _tabulGrid.replaceData(merged).then(function() { + Swal.fire('완료', merged.length + '건이 그리드에 적용되었습니다. 저장 버튼을 눌러 반영하세요.', 'success'); + }); + }, + error: function(xhr, status, error) { + Swal.fire('오류', '엑셀 업로드 실패: ' + (error || status), 'error'); + } + }); +} + +// 파일 input change 바인딩 (한 번만) +$(document).on('change', '#mbomExcelInput', function() { + var file = this.files[0]; + fn_excelUpload(file); + this.value = ''; // 동일 파일 재선택 가능하도록 초기화 +}); - +
+ +
diff --git a/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp b/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp index c81ec23..d213fbc 100644 --- a/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp +++ b/WebContent/WEB-INF/view/salesMng/purchaseListFormPopUp.jsp @@ -105,7 +105,9 @@ body, html {

구매리스트

- + + + @@ -377,7 +379,8 @@ function fn_initGrid() { formatter: function(cell) { return ''; }, - headerSort: false + headerSort: false, + download: false }, // 2. No { @@ -387,7 +390,16 @@ function fn_initGrid() { title: 'No', field: 'ROW_NUM', formatter: "rownum", - frozen: true + frozen: true, + download: false + }, + // 행 식별 키 (수정 금지). 그리드에는 숨기고 다운로드에만 포함. + { + title: 'KEY(수정금지)', + field: 'OBJID', + visible: false, + download: true, + headerSort: false }, // 2-1. 구분 (M-BOM / 수동) // { @@ -630,6 +642,7 @@ function fn_initGrid() { hozAlign: 'left', width: 150, title: '공급업체', + titleDownload: '공급업체', field: 'VENDOR_PM', editor: function(cell, onRendered, success, cancel, editorParams) { return createSelect2Editor(processingVendorList)(cell, onRendered, success, cancel, editorParams); @@ -643,6 +656,13 @@ function fn_initGrid() { } } return value; + }, + accessorDownload: function(value) { + if(!value) return ''; + for(var i = 0; i < processingVendorList.length; i++) { + if(processingVendorList[i].id == value) return processingVendorList[i].text; + } + return value; } }, @@ -652,6 +672,7 @@ function fn_initGrid() { hozAlign: 'center', width: 100, title: '환종', + titleDownload: '환종', field: 'CURRENCY', editor: function(cell, onRendered, success, cancel, editorParams) { return createSelect2Editor(currencyList)(cell, onRendered, success, cancel, editorParams); @@ -669,6 +690,13 @@ function fn_initGrid() { } } return value; + }, + accessorDownload: function(value) { + if(!value) return ''; + for(var i = 0; i < currencyList.length; i++) { + if(currencyList[i].id == value) return currencyList[i].text; + } + return value; } }, // 31. 단가 (수정가능) -> 소재단가 @@ -677,6 +705,7 @@ function fn_initGrid() { hozAlign: 'right', width: 100, title: '소재단가', + titleDownload: '소재단가', field: 'UNIT_PRICE', editor: 'number', editable: true, @@ -708,6 +737,7 @@ function fn_initGrid() { hozAlign: 'center', width: 90, title: '사용여부', + titleDownload: '사용여부', field: 'USE_YN', editor: 'list', editorParams: { @@ -750,6 +780,7 @@ function fn_initGrid() { hozAlign: 'right', width: 100, title: '발주수량', + titleDownload: '발주수량', field: 'PO_QTY', editor: 'number', mutator: function(value, data) { @@ -773,6 +804,7 @@ function fn_initGrid() { hozAlign: 'left', width: 150, title: '가공업체', + titleDownload: '가공업체', field: 'PROCESSING_VENDOR', editor: function(cell, onRendered, success, cancel, editorParams) { return createSelect2Editor(processingVendorList)(cell, onRendered, success, cancel, editorParams); @@ -786,6 +818,13 @@ function fn_initGrid() { } } return value; + }, + accessorDownload: function(value) { + if(!value) return ''; + for(var i = 0; i < processingVendorList.length; i++) { + if(processingVendorList[i].id == value) return processingVendorList[i].text; + } + return value; } }, // 가공단가 (수정가능) @@ -794,6 +833,7 @@ function fn_initGrid() { hozAlign: 'right', width: 100, title: '가공단가', + titleDownload: '가공단가', field: 'PROCESSING_UNIT_PRICE', editor: 'number', editable: true, @@ -1226,6 +1266,318 @@ function fn_mergeSavedData(savedList){ }); } +// 컬럼 정의로부터 다운로드/업로드에 사용할 헤더 텍스트 결정 (titleDownload 우선) +function fn_getHeaderText(c) { + if(c.titleDownload) return c.titleDownload; + if(c.title) return $('
').html(c.title).text().trim(); + return c.field || ''; +} + +// 엑셀 다운로드 (XLSX, ExcelJS). hidden 컬럼인 OBJID도 download:true면 포함. +// - formatter HTML 미적용(raw value 또는 accessorDownload 결과) +// - 헤더 노란 배경, 테두리, 굵은 글씨 +// - 날짜 형식 셀은 텍스트 형식으로 강제 (Excel 자동 변환 차단) +function fn_excelDownload() { + if(!_tabulGrid) return; + + // 1) 내보낼 컬럼 수집 (download:true 우선, 그 외는 visible 기준) + var defs = _tabulGrid.getColumnDefinitions(); + var exportCols = []; + function collectCols(arr) { + for(var i = 0; i < arr.length; i++) { + var c = arr[i]; + if(c.columns) { collectCols(c.columns); continue; } + if(!c.field) continue; + if(c.download === false) continue; + if(c.download !== true && c.visible === false) continue; + exportCols.push({ + field: c.field, + header: fn_getHeaderText(c), + accessorDownload: c.accessorDownload || null, + highlighted: c.downloadHighlighted === true || (typeof c.editor === 'function' || c.editor === true) || (c.title && /background-color/.test(String(c.title))) + }); + } + } + collectCols(defs); + + // 2) Workbook + var workbook = new ExcelJS.Workbook(); + var worksheet = workbook.addWorksheet('구매리스트'); + + // 3) 헤더 + var headerRow = worksheet.addRow(exportCols.map(function(c) { return c.header; })); + headerRow.height = 22; + headerRow.eachCell(function(cell, colNumber) { + var col = exportCols[colNumber - 1]; + cell.font = { bold: true }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { + top: { style: 'thin' }, left: { style: 'thin' }, + bottom: { style: 'thin' }, right: { style: 'thin' } + }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: col.highlighted ? 'FFFFFF00' : 'FFD9E1F2' } + }; + }); + + // 4) 데이터 + var data = _tabulGrid.getData() || []; + for(var r = 0; r < data.length; r++) { + var row = data[r]; + var rowVals = exportCols.map(function(c) { + var raw = row[c.field]; + if(c.accessorDownload) { + try { raw = c.accessorDownload(raw, row, 'xlsx', {}, null); } catch(e) {} + } + return raw === null || raw === undefined ? '' : raw; + }); + var dataRow = worksheet.addRow(rowVals); + dataRow.eachCell({ includeEmpty: true }, function(cell) { + cell.border = { + top: { style: 'thin' }, left: { style: 'thin' }, + bottom: { style: 'thin' }, right: { style: 'thin' } + }; + // YYYY-MM-DD 형태 문자열은 텍스트 형식으로 강제 (Excel이 날짜로 자동 변환하지 않도록) + if(typeof cell.value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(cell.value)) { + cell.numFmt = '@'; + } + }); + } + + // 5) 컬럼 폭 + exportCols.forEach(function(c, i) { + var maxLen = (c.header || '').length; + for(var r = 0; r < data.length; r++) { + var v = data[r][c.field]; + if(v != null) { + var s = String(v); + if(s.length > maxLen) maxLen = s.length; + } + } + worksheet.getColumn(i + 1).width = Math.min(Math.max(maxLen + 2, 10), 40); + }); + + // 6) 다운로드 + var today = new Date(); + var dateStr = today.getFullYear() + '_' + + String(today.getMonth() + 1).padStart(2, '0') + '_' + + String(today.getDate()).padStart(2, '0') + '_' + + String(today.getHours()).padStart(2, '0') + '_' + + String(today.getMinutes()).padStart(2, '0'); + var fileName = '구매리스트_' + dateStr + '.xlsx'; + + workbook.xlsx.writeBuffer().then(function(buffer) { + var blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + setTimeout(function() { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + }).catch(function(err) { + console.error('Excel 다운로드 실패:', err); + Swal.fire('오류', 'Excel 다운로드 중 오류가 발생했습니다.', 'error'); + }); +} + +// columns 정의에서 헤더(title) → field 매핑 자동 생성 (titleDownload 우선) +function fn_buildHeaderFieldMap() { + var map = {}; + var cols = _tabulGrid.getColumnDefinitions(); + function walk(arr) { + for(var i = 0; i < arr.length; i++) { + var c = arr[i]; + if(c.columns) walk(c.columns); + if(c.field) { + var title = fn_getHeaderText(c); + if(title) map[title] = c.field; + } + } + } + walk(cols); + return map; +} + +// Select2 코드 컬럼: 엑셀의 코드명(text) → 코드값(id) 역매핑 +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; + } + return value; +} + +// 엑셀 업로드: 파싱 → 역매핑 → OBJID 무결성 검증 → PART_NO 우선 매칭(중복 시 OBJID) → 그리드 주입 +function fn_excelUpload(file) { + if(!file) return; + var fd = new FormData(); + fd.append('excelFile', file); + fd.append('headerRow', '0'); + + $.ajax({ + url: '/common/parseExcelFile.do', + method: 'POST', + data: fd, + processData: false, + contentType: false, + dataType: 'json', + success: function(res) { + if(!res || res.resultFlag !== 'S') { + Swal.fire('오류', (res && res.message) || '엑셀 파싱에 실패했습니다.', 'error'); + return; + } + var rows = res.rows || []; + if(rows.length === 0) { + Swal.fire('알림', '읽어들인 행이 없습니다.', 'warning'); + return; + } + var headerFieldMap = fn_buildHeaderFieldMap(); + + // 기존 행: PART_NO별 그룹 + OBJID 화이트리스트 + var existing = _tabulGrid.getData() || []; + var existingByPartNo = {}; + var existingObjidSet = {}; + for(var i = 0; i < existing.length; i++) { + var pno = String(existing[i].PART_NO || ''); + if(!existingByPartNo[pno]) existingByPartNo[pno] = []; + existingByPartNo[pno].push(existing[i]); + var oid = String(existing[i].OBJID || ''); + if(oid) existingObjidSet[oid] = true; + } + + // 1) 헤더 → field 변환 + Select2 역매핑 + var converted = []; + for(var i = 0; i < rows.length; i++) { + var src = rows[i]; + var out = {}; + for(var header in src) { + if(!src.hasOwnProperty(header)) continue; + var field = headerFieldMap[header]; + if(!field) continue; + out[field] = fn_reverseSelectValue(field, src[header]); + } + converted.push(out); + } + + // 2) OBJID 무결성 검증 + var invalid = []; + for(var i = 0; i < converted.length; i++) { + var oid = String(converted[i].OBJID || ''); + if(oid && !existingObjidSet[oid]) { + invalid.push({ row: i + 2, objid: oid }); + } + } + if(invalid.length > 0) { + var msg = 'KEY(OBJID) 컬럼이 변경되어 업로드를 중단합니다.\n해당 셀은 수정 금지입니다.\n신규 행은 KEY 셀을 비워두세요.\n\n'; + for(var i = 0; i < Math.min(invalid.length, 5); i++) { + msg += ' - ' + invalid[i].row + '행: ' + invalid[i].objid + '\n'; + } + if(invalid.length > 5) msg += ' ... (총 ' + invalid.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + // 3) PART_NO 우선 매칭 (중복 시 OBJID disambiguate) + var usedObjids = {}; + function pickMatch(uploadRow) { + var pno = String(uploadRow.PART_NO || ''); + var oid = String(uploadRow.OBJID || ''); + var candidates = existingByPartNo[pno] || []; + if(candidates.length === 0) return null; + if(candidates.length === 1) { + var only = candidates[0]; + var k = String(only.OBJID || ''); + if(usedObjids[k]) return null; + usedObjids[k] = true; + return only; + } + if(oid) { + for(var j = 0; j < candidates.length; j++) { + var c = candidates[j]; + var ck = String(c.OBJID || ''); + if(ck === oid && !usedObjids[ck]) { + usedObjids[ck] = true; + return c; + } + } + } + for(var j = 0; j < candidates.length; j++) { + var c = candidates[j]; + var ck = String(c.OBJID || ''); + if(!usedObjids[ck]) { + usedObjids[ck] = true; + return c; + } + } + return null; + } + + var merged = []; + var unmatched = []; + for(var i = 0; i < converted.length; i++) { + var out = converted[i]; + var base = pickMatch(out); + if(!base) { + unmatched.push({ row: i + 2, partNo: String(out.PART_NO || '') }); + continue; + } + merged.push($.extend({}, base, out)); + } + if(unmatched.length > 0) { + var msg = '기존에 없는 행이 포함되어 업로드를 중단합니다.\n신규 행은 추가할 수 없습니다.\n\n'; + for(var i = 0; i < Math.min(unmatched.length, 5); i++) { + msg += ' - ' + unmatched[i].row + '행: PART_NO=' + (unmatched[i].partNo || '(빈값)') + '\n'; + } + if(unmatched.length > 5) msg += ' ... (총 ' + unmatched.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + // 4) 누락 검증: 매칭되지 않은 기존 행이 있으면 차단 (행 삭제 금지) + var missing = []; + for(var i = 0; i < existing.length; i++) { + var oid = String(existing[i].OBJID || ''); + if(oid && !usedObjids[oid]) { + missing.push({ partNo: String(existing[i].PART_NO || ''), objid: oid }); + } + } + if(missing.length > 0) { + var msg = '엑셀에서 행이 삭제되어 업로드를 중단합니다.\n행 삭제는 허용되지 않습니다.\n\n'; + for(var i = 0; i < Math.min(missing.length, 5); i++) { + msg += ' - PART_NO=' + (missing[i].partNo || '(빈값)') + '\n'; + } + if(missing.length > 5) msg += ' ... (총 ' + missing.length + '건)'; + Swal.fire('오류', msg, 'error'); + return; + } + + _tabulGrid.replaceData(merged).then(function() { + Swal.fire('완료', merged.length + '건이 그리드에 적용되었습니다. 저장 버튼을 눌러 반영하세요.', 'success'); + }); + }, + error: function(xhr, status, error) { + Swal.fire('오류', '엑셀 업로드 실패: ' + (error || status), 'error'); + } + }); +} + +// 파일 input change 바인딩 +$(document).on('change', '#purchaseExcelInput', function() { + var file = this.files[0]; + fn_excelUpload(file); + this.value = ''; +}); + // 저장 function fn_save() { var gridData = _tabulGrid.getData(); diff --git a/src/com/pms/common/utils/ExcelParseUtil.java b/src/com/pms/common/utils/ExcelParseUtil.java new file mode 100644 index 0000000..78045f0 --- /dev/null +++ b/src/com/pms/common/utils/ExcelParseUtil.java @@ -0,0 +1,181 @@ +package com.pms.common.utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; + +/** + * 엑셀/CSV 파일을 헤더행 기반 Map 리스트로 파싱하는 공용 유틸. + * .xls / .xlsx / .csv 지원. + */ +public class ExcelParseUtil { + + private ExcelParseUtil() {} + + /** + * 첫 시트(또는 CSV 본체)의 지정한 헤더행을 key로, 이후 모든 행을 value로 하는 Map 리스트를 반환. + * + * @param file 업로드된 파일 + * @param headerRowIdx 헤더행 인덱스 (0-base, 일반적으로 0) + */ + public static List> parse(File file, int headerRowIdx) throws Exception { + String name = file.getName() == null ? "" : file.getName().toLowerCase(); + if (name.endsWith(".csv")) { + return parseCsv(file, headerRowIdx); + } + return parseXlsx(file, headerRowIdx); + } + + private static List> parseXlsx(File file, int headerRowIdx) throws Exception { + List> resultList = new ArrayList>(); + + FileInputStream fis = null; + Workbook workBook = null; + try { + fis = new FileInputStream(file); + workBook = WorkbookFactory.create(fis); + Sheet sheet = workBook.getSheetAt(0); + + Row headerRow = sheet.getRow(headerRowIdx); + if (headerRow == null) return resultList; + + int colCount = headerRow.getLastCellNum(); + String[] headers = new String[colCount]; + DataFormatter formatter = new DataFormatter(); + + for (int c = 0; c < colCount; c++) { + Cell cell = headerRow.getCell(c); + headers[c] = cell == null ? "" : formatter.formatCellValue(cell).trim(); + } + + int lastRow = sheet.getLastRowNum(); + for (int r = headerRowIdx + 1; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row == null) continue; + + Map rowMap = new LinkedHashMap(); + boolean hasValue = false; + + for (int c = 0; c < colCount; c++) { + String header = headers[c]; + if (header == null || header.isEmpty()) continue; + + Cell cell = row.getCell(c); + String value = cell == null ? "" : formatter.formatCellValue(cell); + if (value == null) value = ""; + value = value.trim(); + + rowMap.put(header, value); + if (!value.isEmpty()) hasValue = true; + } + + if (hasValue) resultList.add(rowMap); + } + } finally { + if (workBook != null) { try { workBook.close(); } catch (Exception ignore) {} } + if (fis != null) { try { fis.close(); } catch (Exception ignore) {} } + } + + return resultList; + } + + private static List> parseCsv(File file, int headerRowIdx) throws Exception { + List> result = new ArrayList>(); + + BufferedReader br = null; + try { + br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8")); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + String csv = sb.toString(); + if (csv.length() > 0 && csv.charAt(0) == '\uFEFF') { + csv = csv.substring(1); + } + + List> rows = parseCsvText(csv); + if (rows.size() <= headerRowIdx) return result; + + List headerRow = rows.get(headerRowIdx); + for (int r = headerRowIdx + 1; r < rows.size(); r++) { + List dataRow = rows.get(r); + Map rowMap = new LinkedHashMap(); + boolean hasValue = false; + for (int c = 0; c < headerRow.size(); c++) { + String header = headerRow.get(c); + if (header == null || header.isEmpty()) continue; + String value = c < dataRow.size() ? dataRow.get(c) : ""; + if (value == null) value = ""; + value = value.trim(); + rowMap.put(header, value); + if (!value.isEmpty()) hasValue = true; + } + if (hasValue) result.add(rowMap); + } + } finally { + if (br != null) { try { br.close(); } catch (Exception ignore) {} } + } + return result; + } + + /** + * RFC 4180 기준 단순 CSV 파서. 따옴표로 감싼 필드 내부의 콤마/개행/이중따옴표를 처리한다. + */ + private static List> parseCsvText(String csv) { + List> rows = new ArrayList>(); + List currentRow = new ArrayList(); + StringBuilder field = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < csv.length(); i++) { + char ch = csv.charAt(i); + if (inQuotes) { + if (ch == '"') { + if (i + 1 < csv.length() && csv.charAt(i + 1) == '"') { + field.append('"'); + i++; + } else { + inQuotes = false; + } + } else { + field.append(ch); + } + } else { + if (ch == '"') { + inQuotes = true; + } else if (ch == ',') { + currentRow.add(field.toString()); + field.setLength(0); + } else if (ch == '\r') { + // 다음 \n에서 행 종료 + } else if (ch == '\n') { + currentRow.add(field.toString()); + field.setLength(0); + rows.add(currentRow); + currentRow = new ArrayList(); + } else { + field.append(ch); + } + } + } + if (field.length() > 0 || !currentRow.isEmpty()) { + currentRow.add(field.toString()); + rows.add(currentRow); + } + return rows; + } +} diff --git a/src/com/pms/controller/CommonController.java b/src/com/pms/controller/CommonController.java index f7e3f8b..36fd7ee 100644 --- a/src/com/pms/controller/CommonController.java +++ b/src/com/pms/controller/CommonController.java @@ -19,6 +19,7 @@ import com.oreilly.servlet.MultipartRequest; import com.pms.common.FileRenameClass; import com.pms.common.utils.CommonUtils; import com.pms.common.utils.Constants; +import com.pms.common.utils.ExcelParseUtil; import com.pms.service.CommonService; @Controller @@ -122,6 +123,75 @@ public class CommonController { return "/ajax/ajaxResult"; } + /** + * 엑셀 파일을 임시로 업로드 받아 헤더행 기반으로 Map 리스트로 파싱하여 JSON 응답. + * 파일은 DB 등록 없이 임시 디렉토리에 저장 후 파싱 완료 시 삭제한다. + * + * 요청: multipart/form-data, 파일 input name="excelFile", (선택) headerRow + * 응답: { resultFlag: "S"/"F", rows: [{헤더: 값, ...}], message } + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + @ResponseBody + @RequestMapping("/common/parseExcelFile.do") + public Map parseExcelFile(HttpServletRequest request) { + Map result = new HashMap(); + File uploadedFile = null; + FileRenameClass frc = null; + try { + String tempDir = Constants.FILE_STORAGE + File.separator + "temp_excel"; + File dir = new File(tempDir); + if (!dir.exists()) dir.mkdirs(); + + int maxSize = 1024 * 1024 * 100; // 100MB + frc = new FileRenameClass(); + MultipartRequest multi = new MultipartRequest(request, tempDir, maxSize, "UTF-8", frc); + + int headerRow = 0; + String headerRowParam = multi.getParameter("headerRow"); + if (headerRowParam != null && !"".equals(headerRowParam)) { + try { headerRow = Integer.parseInt(headerRowParam); } catch (Exception ignore) {} + } + + List> fileList = frc.getFileList(); + if (fileList != null && !fileList.isEmpty()) { + Map fm = fileList.get(0); + String fullPath = CommonUtils.checkNull(fm.get("savedFullFileName")); + if (!"".equals(fullPath)) { + uploadedFile = new File(fullPath); + } else { + String path = CommonUtils.checkNull(fm.get("filePath")); + String savedName = CommonUtils.checkNull(fm.get("savedFileName")); + if (!"".equals(savedName)) { + if ("".equals(path)) path = tempDir; + uploadedFile = new File(path + File.separator + savedName); + } + } + } + + if (uploadedFile == null || !uploadedFile.exists()) { + result.put("resultFlag", "F"); + result.put("message", "업로드된 파일을 찾을 수 없습니다."); + return result; + } + + List> rows = ExcelParseUtil.parse(uploadedFile, headerRow); + result.put("resultFlag", "S"); + result.put("rows", rows); + } catch (Exception e) { + e.printStackTrace(); + result.put("resultFlag", "F"); + result.put("message", e.getMessage()); + } finally { + try { + if (uploadedFile != null && uploadedFile.exists()) { + uploadedFile.delete(); + } + } catch (Exception ignore) {} + if (frc != null) frc.clear(); + } + return result; + } + /** * 파일 목록 조회(ajax) * @param paramMap