M-BOM, 구매리스트 팝업 엑셀 업로드 기능 추가 및 다운로드 양식 개선 #206

Merged
hjjeong merged 1 commits from V20260210 into main 2026-04-24 07:30:30 +00:00
4 changed files with 943 additions and 16 deletions

View File

@@ -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 $('<div>').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 = ''; // 동일 파일 재선택 가능하도록 초기화
});
</script>
</head>
</head>
<body>
<div>
<input type="file" id="mbomExcelInput" accept=".xlsx,.xls,.csv" style="display:none;">
<input type="button" value="Excel Upload" class="plm_btns" id="btnExcelUpload" style="float:right; margin-right: 5px; margin-bottom: 5px;" onclick="document.getElementById('mbomExcelInput').click();">
<input type="button" value="Excel Download" class="plm_btns" id="btnExcel" style="float:right; margin-right: 5px; margin-bottom: 5px;" onclick="fn_excel();">
</div>
<div id="mBomTableWrap"></div>

View File

@@ -105,7 +105,9 @@ body, html {
<div class="header">
<h3 style="margin: 0 0 10px 0; float: left;">구매리스트</h3>
<div style="float: right;">
<input type="button" class="plm_btns excelIcon excelBtn" value="Excel Download" title="Excel Download">
<input type="file" id="purchaseExcelInput" accept=".xlsx,.xls,.csv" style="display:none;">
<input type="button" value="Excel Upload" class="plm_btns" onclick="document.getElementById('purchaseExcelInput').click();" style="margin-right: 5px;">
<input type="button" class="plm_btns excelIcon" value="Excel Download" title="Excel Download" onclick="fn_excelDownload();">
<input type="button" value="견적요청서 생성" class="plm_btns" onclick="fn_createQuotationRequest();" style="margin-right: 5px; background-color: #4CAF50; border-color: #4CAF50;">
<input type="button" value="저장" class="plm_btns" onclick="fn_save();" style="margin-right: 5px;">
<input type="button" value="닫기" class="plm_btns" onclick="window.close();" style="margin-right: 5px;">
@@ -377,7 +379,8 @@ function fn_initGrid() {
formatter: function(cell) {
return '<input type="checkbox" class="rowCheck">';
},
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: '<span style="background-color: #FFFF00; padding: 2px 5px;">공급업체</span>',
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: '<span style="background-color: #FFFF00; padding: 2px 5px;">환종</span>',
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: '<span style="background-color: #FFFF00; padding: 2px 5px;">소재단가</span>',
titleDownload: '소재단가',
field: 'UNIT_PRICE',
editor: 'number',
editable: true,
@@ -708,6 +737,7 @@ function fn_initGrid() {
hozAlign: 'center',
width: 90,
title: '<span style="background-color: #FFFF00; padding: 2px 5px;">사용여부</span>',
titleDownload: '사용여부',
field: 'USE_YN',
editor: 'list',
editorParams: {
@@ -750,6 +780,7 @@ function fn_initGrid() {
hozAlign: 'right',
width: 100,
title: '<span style="background-color: #FFFF00; padding: 2px 5px;">발주수량</span>',
titleDownload: '발주수량',
field: 'PO_QTY',
editor: 'number',
mutator: function(value, data) {
@@ -773,6 +804,7 @@ function fn_initGrid() {
hozAlign: 'left',
width: 150,
title: '<span style="background-color: #FFFF00; padding: 2px 5px;">가공업체</span>',
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: '<span style="background-color: #FFFF00; padding: 2px 5px;">가공단가</span>',
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 $('<div>').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();

View File

@@ -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<Map<String, String>> 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<Map<String, String>> parseXlsx(File file, int headerRowIdx) throws Exception {
List<Map<String, String>> resultList = new ArrayList<Map<String, String>>();
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<String, String> rowMap = new LinkedHashMap<String, String>();
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<Map<String, String>> parseCsv(File file, int headerRowIdx) throws Exception {
List<Map<String, String>> result = new ArrayList<Map<String, String>>();
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<List<String>> rows = parseCsvText(csv);
if (rows.size() <= headerRowIdx) return result;
List<String> headerRow = rows.get(headerRowIdx);
for (int r = headerRowIdx + 1; r < rows.size(); r++) {
List<String> dataRow = rows.get(r);
Map<String, String> rowMap = new LinkedHashMap<String, String>();
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<List<String>> parseCsvText(String csv) {
List<List<String>> rows = new ArrayList<List<String>>();
List<String> currentRow = new ArrayList<String>();
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<String>();
} else {
field.append(ch);
}
}
}
if (field.length() > 0 || !currentRow.isEmpty()) {
currentRow.add(field.toString());
rows.add(currentRow);
}
return rows;
}
}

View File

@@ -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<String, Object> parseExcelFile(HttpServletRequest request) {
Map<String, Object> result = new HashMap<String, Object>();
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<Map<String, Object>> fileList = frc.getFileList();
if (fileList != null && !fileList.isEmpty()) {
Map<String, Object> 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<Map<String, String>> 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