|
| 1 | +const ExcelJS = require('exceljs'); |
| 2 | +const excelStyleHelper = require('./excel-style-helper'); |
| 3 | +const FileUtils = require('./file-utils'); |
| 4 | + |
| 5 | +/** |
| 6 | + * 엑셀 생성 관련 함수들을 담당하는 모듈 |
| 7 | + */ |
| 8 | +class ExcelGenerator { |
| 9 | + constructor() { |
| 10 | + this.fileUtils = FileUtils; |
| 11 | + } |
| 12 | + |
| 13 | + /** |
| 14 | + * 엑셀 파일 생성 |
| 15 | + * @param {Object} options - 생성 옵션 |
| 16 | + * @returns {Promise<string>} 생성된 파일 경로 |
| 17 | + */ |
| 18 | + async generateExcel(options) { |
| 19 | + const { |
| 20 | + sheets, |
| 21 | + outputPath, |
| 22 | + createSeparateToc = false, |
| 23 | + createdSheetNames = [], |
| 24 | + createdSheetCounts = [] |
| 25 | + } = options; |
| 26 | + |
| 27 | + console.log('-------------------------------------------------------------------------------'); |
| 28 | + console.log(`[${outputPath}] START WORK`); |
| 29 | + console.log('-------------------------------------------------------------------------------'); |
| 30 | + |
| 31 | + const workbook = new ExcelJS.Workbook(); |
| 32 | + const createdSheets = []; |
| 33 | + |
| 34 | + // 목차 시트를 맨 처음에 생성 (내용은 나중에 채움) |
| 35 | + let tocSheet = null; |
| 36 | + |
| 37 | + for (const sheetDef of sheets) { |
| 38 | + // robust use 속성 체크 |
| 39 | + if (!this.isSheetEnabled(sheetDef)) { |
| 40 | + console.log(`[SKIP] Sheet '${sheetDef.name}' is disabled (use=false)`); |
| 41 | + continue; |
| 42 | + } |
| 43 | + |
| 44 | + // 첫 번째 활성 시트일 때 목차 시트 생성 |
| 45 | + if (!tocSheet) { |
| 46 | + tocSheet = workbook.addWorksheet('목차'); |
| 47 | + console.log(`[목차] 맨 첫 번째 시트로 생성됨`); |
| 48 | + } |
| 49 | + |
| 50 | + const sheet = workbook.addWorksheet(sheetDef.name); |
| 51 | + const recordCount = sheetDef.recordCount || 0; |
| 52 | + |
| 53 | + // 실제 생성된 시트명 가져오기 (31자 초과시 잘린 이름) |
| 54 | + const actualSheetName = sheet.name; |
| 55 | + |
| 56 | + // 집계 컬럼이 지정된 경우 집계 데이터 계산 |
| 57 | + let aggregateData = null; |
| 58 | + if (sheetDef.aggregateColumn && recordCount > 0) { |
| 59 | + aggregateData = this.calculateAggregateData(sheetDef.aggregateColumn, sheetDef.data); |
| 60 | + console.log(`\t[집계] ${sheetDef.aggregateColumn} 컬럼 집계: ${aggregateData.map(item => `${item.key}(${item.count})`).join(', ')}`); |
| 61 | + } |
| 62 | + |
| 63 | + createdSheets.push({ |
| 64 | + displayName: sheetDef.name, |
| 65 | + originalName: sheetDef.name, |
| 66 | + tabName: actualSheetName, |
| 67 | + recordCount: recordCount, |
| 68 | + aggregateColumn: sheetDef.aggregateColumn, |
| 69 | + aggregateData: aggregateData |
| 70 | + }); |
| 71 | + |
| 72 | + // 시트명이 잘렸는지 확인하고 로그 출력 |
| 73 | + if (sheetDef.name !== actualSheetName) { |
| 74 | + console.log(`\t[WARN] Sheet name truncated: '${sheetDef.name}' → '${actualSheetName}'`); |
| 75 | + } |
| 76 | + |
| 77 | + if (recordCount > 0) { |
| 78 | + // 데이터와 스타일 적용 (1행부터 시작) |
| 79 | + excelStyleHelper.applySheetStyle(sheet, sheetDef.data, sheetDef.style, 1); |
| 80 | + |
| 81 | + // 데이터 추가 후 맨 앞에 DB 정보 행 삽입 |
| 82 | + sheet.spliceRows(1, 0, [`📊 출처: ${sheetDef.dbKey} DB`]); |
| 83 | + sheet.spliceRows(2, 0, []); // 빈 행 추가 |
| 84 | + |
| 85 | + // DB 정보 셀 스타일링 |
| 86 | + const dbCell = sheet.getCell('A1'); |
| 87 | + dbCell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } }; |
| 88 | + dbCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '366092' } }; |
| 89 | + |
| 90 | + console.log(`\t[DB정보] ${sheetDef.dbKey} DB 출처 표시 완료`); |
| 91 | + } else { |
| 92 | + // 데이터가 없는 경우 |
| 93 | + sheet.addRow([`📊 출처: ${sheetDef.dbKey} DB`]); |
| 94 | + sheet.addRow([]); |
| 95 | + sheet.addRow(['데이터가 없습니다.']); |
| 96 | + |
| 97 | + // 스타일링 |
| 98 | + sheet.getCell('A1').font = { bold: true, size: 11, color: { argb: 'FFFFFF' } }; |
| 99 | + sheet.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '366092' } }; |
| 100 | + sheet.getCell('A3').font = { italic: true, color: { argb: '999999' } }; |
| 101 | + |
| 102 | + console.log(`\t[DB정보] ${sheetDef.dbKey} DB 출처 표시 완료 (데이터 없음)`); |
| 103 | + } |
| 104 | + console.log(`\t---> ${recordCount} rows were selected `); |
| 105 | + } |
| 106 | + |
| 107 | + // 목차 시트에 내용 채우기 |
| 108 | + if (createdSheets.length > 0 && tocSheet) { |
| 109 | + // excel-style-helper 모듈의 함수 사용하여 안전한 목차 생성 |
| 110 | + excelStyleHelper.populateTableOfContents(tocSheet, createdSheets); |
| 111 | + |
| 112 | + // 목차 시트를 첫 번째로 이동 (ExcelJS에서는 worksheets가 읽기 전용이므로 다른 방법 사용) |
| 113 | + // 목차 시트는 이미 첫 번째로 생성되었으므로 추가 조작 불필요 |
| 114 | + |
| 115 | + console.log(`[목차] 내용 채우기 완료 (총 ${createdSheets.length}개 시트)`); |
| 116 | + |
| 117 | + if (createSeparateToc) { |
| 118 | + await this.createSeparateTocFile(outputPath, createdSheets, createdSheetCounts); |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + console.log(`\nGenerating excel file ... `); |
| 123 | + console.log(`Wating a few seconds ... `); |
| 124 | + await workbook.xlsx.writeFile(outputPath); |
| 125 | + console.log(`\n\n[${outputPath}] Excel file created `); |
| 126 | + console.log('-------------------------------------------------------------------------------\n\n'); |
| 127 | + |
| 128 | + return outputPath; |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * 별도 목차 파일 생성 |
| 133 | + * @param {string} outputPath - 원본 출력 파일 경로 |
| 134 | + * @param {Array} createdSheetNames - 생성된 시트 정보 |
| 135 | + * @param {Array} createdSheetCounts - 시트별 데이터 개수 |
| 136 | + */ |
| 137 | + async createSeparateTocFile(outputPath, createdSheetNames, createdSheetCounts) { |
| 138 | + const tocWb = new ExcelJS.Workbook(); |
| 139 | + const tocOnly = tocWb.addWorksheet('목차'); |
| 140 | + tocOnly.addRow(['No', 'Sheet Name', 'Data Count']); |
| 141 | + |
| 142 | + createdSheetNames.forEach((obj, idx) => { |
| 143 | + const row = tocOnly.addRow([idx + 1, obj.displayName, createdSheetCounts[idx]]); |
| 144 | + row.getCell(2).font = { color: { argb: '0563C1' }, underline: true }; |
| 145 | + row.getCell(3).font = { color: { argb: '0563C1' }, underline: true }; |
| 146 | + }); |
| 147 | + |
| 148 | + tocOnly.getRow(1).font = { bold: true }; |
| 149 | + tocOnly.columns = [ |
| 150 | + { header: 'No', key: 'no', width: 6 }, |
| 151 | + { header: 'Sheet Name', key: 'name', width: 30 }, |
| 152 | + { header: 'Data Count', key: 'count', width: 12 } |
| 153 | + ]; |
| 154 | + |
| 155 | + const tocExt = FileUtils.getExtension(outputPath); |
| 156 | + const tocBase = outputPath.slice(0, -tocExt.length); |
| 157 | + const tocFile = `${tocBase}_목차_${FileUtils.getNowTimestampStr()}${tocExt}`; |
| 158 | + |
| 159 | + await tocWb.xlsx.writeFile(tocFile); |
| 160 | + console.log(`[목차] 별도 엑셀 파일 생성: ${tocFile}`); |
| 161 | + } |
| 162 | + |
| 163 | + /** |
| 164 | + * 시트가 활성화되어 있는지 확인 |
| 165 | + * @param {Object} sheetDef - 시트 정의 객체 |
| 166 | + * @returns {boolean} 활성화 여부 |
| 167 | + */ |
| 168 | + isSheetEnabled(sheetDef) { |
| 169 | + let use = true; |
| 170 | + // JSON: use 속성 |
| 171 | + if (typeof sheetDef.use !== 'undefined') { |
| 172 | + if ( |
| 173 | + sheetDef.use === false || |
| 174 | + sheetDef.use === 0 || |
| 175 | + sheetDef.use === 'false' || |
| 176 | + sheetDef.use === '0' || |
| 177 | + sheetDef.use === '' || |
| 178 | + sheetDef.use === null |
| 179 | + ) use = false; |
| 180 | + } |
| 181 | + // XML: $.use 속성 |
| 182 | + else if (sheetDef.hasOwnProperty('$') && typeof sheetDef.$.use !== 'undefined') { |
| 183 | + const val = sheetDef.$.use; |
| 184 | + if ( |
| 185 | + val === false || |
| 186 | + val === 0 || |
| 187 | + val === 'false' || |
| 188 | + val === '0' || |
| 189 | + val === '' || |
| 190 | + val === null |
| 191 | + ) use = false; |
| 192 | + } |
| 193 | + return use; |
| 194 | + } |
| 195 | + |
| 196 | + /** |
| 197 | + * 집계 데이터 계산 |
| 198 | + * @param {string} aggregateColumn - 집계 컬럼명 |
| 199 | + * @param {Array} data - 데이터 배열 |
| 200 | + * @returns {Array} 집계 결과 |
| 201 | + */ |
| 202 | + calculateAggregateData(aggregateColumn, data) { |
| 203 | + if (!data || data.length === 0) return []; |
| 204 | + |
| 205 | + const aggregateMap = {}; |
| 206 | + |
| 207 | + data.forEach(row => { |
| 208 | + const value = row[aggregateColumn]; |
| 209 | + if (value !== null && value !== undefined) { |
| 210 | + const key = String(value).trim(); |
| 211 | + aggregateMap[key] = (aggregateMap[key] || 0) + 1; |
| 212 | + } |
| 213 | + }); |
| 214 | + |
| 215 | + // 집계 결과를 배열로 변환 (건수가 많은 순으로 정렬) |
| 216 | + return Object.entries(aggregateMap) |
| 217 | + .map(([key, count]) => ({ key, count })) |
| 218 | + .sort((a, b) => b.count - a.count); |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * 엑셀 파일 경로 생성 |
| 223 | + * @param {string} basePath - 기본 경로 |
| 224 | + * @param {string} timestamp - 타임스탬프 |
| 225 | + * @returns {string} 생성된 파일 경로 |
| 226 | + */ |
| 227 | + generateOutputPath(basePath, timestamp) { |
| 228 | + const ext = FileUtils.getExtension(basePath); |
| 229 | + const base = basePath.slice(0, -ext.length); |
| 230 | + return `${base}_${timestamp}${ext}`; |
| 231 | + } |
| 232 | + |
| 233 | + /** |
| 234 | + * 엑셀 파일 검증 |
| 235 | + * @param {string} filePath - 파일 경로 |
| 236 | + * @returns {boolean} 유효성 여부 |
| 237 | + */ |
| 238 | + validateExcelFile(filePath) { |
| 239 | + const ext = this.fileUtils.getExtension(filePath).toLowerCase(); |
| 240 | + return ext === '.xlsx' || ext === '.xls'; |
| 241 | + } |
| 242 | + |
| 243 | + /** |
| 244 | + * 엑셀 파일 크기 확인 |
| 245 | + * @param {string} filePath - 파일 경로 |
| 246 | + * @returns {number} 파일 크기 (바이트) |
| 247 | + */ |
| 248 | + getExcelFileSize(filePath) { |
| 249 | + return FileUtils.getFileSize(filePath); |
| 250 | + } |
| 251 | + |
| 252 | + /** |
| 253 | + * 엑셀 파일 생성 시간 확인 |
| 254 | + * @param {string} filePath - 파일 경로 |
| 255 | + * @returns {Date} 생성 시간 |
| 256 | + */ |
| 257 | + getExcelFileCreatedTime(filePath) { |
| 258 | + return FileUtils.getModifiedTime(filePath); |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +module.exports = ExcelGenerator; |
0 commit comments