/** * SmartExcelUpload — 템플릿 생성기 * ExcelJS 기반: 드롭다운, VLOOKUP 수식, INDIRECT 동적 드롭다운, * 참조시트, 조건부서식, _meta 해시시트 */ import ExcelJS from "exceljs"; import type { SmartExcelUploadConfig, ItemProcessMapping, } from "./types"; /** 카테고리 값들로 해시 생성 */ function generateHash(data: string): string { let hash = 0; for (let i = 0; i < data.length; i++) { const char = data.charCodeAt(i); hash = ((hash << 5) - hash + char) | 0; } return Math.abs(hash).toString(36); } /** 엑셀 컬럼 문자 (1→A, 2→B, ..., 27→AA) */ function colLetter(index: number): string { let result = ""; let n = index; while (n > 0) { n--; result = String.fromCharCode(65 + (n % 26)) + result; n = Math.floor(n / 26); } return result; } /** 품목코드를 엑셀 이름 범위에 사용 가능한 이름으로 변환 */ function sanitizeNameForExcel(itemCode: string): string { // 엑셀 이름 범위 규칙: 문자/밑줄로 시작, 영숫자/밑줄만 허용 return "P_" + itemCode.replace(/[^a-zA-Z0-9]/g, "_"); } export interface GenerateTemplateOptions { config: SmartExcelUploadConfig; /** 참조시트 데이터 (DB에서 조회한 검사기준 등) */ referenceData?: Record[]; /** 시트별 드롭다운 옵션 (카테고리 값 등) */ dropdownOptions?: Record; /** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */ itemProcessMappings?: ItemProcessMapping[]; /** 추가 메타 정보 */ extraMeta?: Record; } export async function generateTemplate( options: GenerateTemplateOptions ): Promise { const { config, referenceData, dropdownOptions, itemProcessMappings, extraMeta } = options; const workbook = new ExcelJS.Workbook(); workbook.creator = "WACE ERP"; workbook.created = new Date(); // 해시 소스: 참조 데이터 + 드롭다운 옵션 + 품목공정매핑 const hashSource = JSON.stringify({ referenceData, dropdownOptions, itemProcessMappings: itemProcessMappings?.map(m => ({ itemCode: m.itemCode, processes: m.processes.map(p => p.name), })), }); const versionHash = generateHash(hashSource); // ═══════════════════ 1. 참조시트 생성 ═══════════════════ let refSheet: ExcelJS.Worksheet | null = null; const refDataRows = referenceData || config.referenceSheet?.data || []; const refCols = config.referenceSheet?.columns || []; if (config.referenceSheet && refCols.length > 0) { refSheet = workbook.addWorksheet(config.referenceSheet.name, { state: "veryHidden", }); // 헤더 const headerRow = refSheet.addRow(refCols.map((c) => c.label)); headerRow.eachCell((cell) => { cell.font = { bold: true, size: 10 }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } }; cell.border = { bottom: { style: "thin", color: { argb: "FF94A3B8" } } }; }); for (const row of refDataRows) { refSheet.addRow(refCols.map((c) => row[c.key] ?? "")); } refCols.forEach((_, i) => { refSheet!.getColumn(i + 1).width = 20; }); } // ═══════════════════ 1-B. 합격기준 옵션 시트 (INDIRECT용) ═══════════════════ // 검사기준별 판단기준에 따라 합격기준 드롭다운을 동적으로 변경 // 한글은 엑셀 이름 범위에 사용 불가 → 숫자 인덱스 ACC_1, ACC_2... 사용 // INDIRECT("ACC_" & MATCH(검사기준셀, 검사기준정보!A열, 0)) 방식 // ═══════════════════ 1-B. INDIRECT ACC_ 동적 드롭다운 옵션 시트 ═══════════════════ const hasAccPrefix = config.sheets.some(s => s.columns.some(c => c.dropdown?.indirectPrefix === "ACC_")); if (refDataRows.length > 0 && hasAccPrefix && config.indirectOptions) { const accSheet = workbook.addWorksheet("_합격기준옵션", { state: "veryHidden" }); const { conditionColumn, optionsByCondition, selectionOptionsColumn } = config.indirectOptions; for (let i = 0; i < refDataRows.length; i++) { const row = refDataRows[i]; const condValue = row[conditionColumn] || ""; const rowNum = i + 1; const safeName = `ACC_${rowNum}`; // 조건값에 매칭되는 고정 옵션 확인 let options: string[] = optionsByCondition[condValue] || []; // 선택옵션 컬럼에서 동적으로 가져오기 (콤마 구분) if (options.length === 0 && selectionOptionsColumn) { const selOpts = row[selectionOptionsColumn] || ""; if (selOpts) { options = selOpts.split(",").map((s: string) => s.trim()).filter(Boolean); } } if (options.length > 0) { for (let j = 0; j < options.length; j++) { accSheet.getCell(`${colLetter(j + 1)}${rowNum}`).value = options[j]; } const endCol = colLetter(options.length); try { workbook.definedNames.add(`'_합격기준옵션'!$A$${rowNum}:$${endCol}$${rowNum}`, safeName); } catch { /* skip */ } } // 매칭 안 되면 이름 범위 미등록 → INDIRECT 실패 → 자유 입력 } } // ═══════════════════ 2. 품목공정 매핑 시트 (INDIRECT용) ═══════════════════ const hasItemProcess = itemProcessMappings && itemProcessMappings.length > 0; if (hasItemProcess) { const procSheet = workbook.addWorksheet("_품목공정", { state: "veryHidden" }); procSheet.getColumn(1).width = 20; // 품목별로 열에 공정 목록 배치, 이름 범위 등록 // 방식: 각 품목코드를 열 방향으로 배치 (A열: 품목1 공정들, B열: 품목2 공정들...) // → 이름 범위 수가 너무 많아질 수 있으므로, 행 방향으로 배치 // 방식 변경: 품목코드를 A열, 공정을 B열~에 넣고, 이름 범위로 등록 // → ExcelJS는 definedNames 지원 for (let i = 0; i < itemProcessMappings!.length; i++) { const mapping = itemProcessMappings![i]; const safeName = sanitizeNameForExcel(mapping.itemCode); const rowNum = i + 1; // A열: 품목코드 (참조용) procSheet.getCell(`A${rowNum}`).value = mapping.itemCode; // B열~: 공정명들 const procs = mapping.processes; if (procs.length > 0) { for (let j = 0; j < procs.length; j++) { procSheet.getCell(`${colLetter(j + 2)}${rowNum}`).value = procs[j].name; } // 이름 범위 등록: add(rangeString, name) const startCol = colLetter(2); // B const endCol = colLetter(procs.length + 1); workbook.definedNames.add(`'_품목공정'!$${startCol}$${rowNum}:$${endCol}$${rowNum}`, safeName); } // 공정 없는 품목: 이름 범위 미등록 → INDIRECT 실패 → 드롭다운 안 뜸 (자유 입력) } // 품목코드 목록도 별도 시트에 (드롭다운용) const itemListSheet = workbook.addWorksheet("_품목목록", { state: "veryHidden" }); itemProcessMappings!.forEach((m, i) => { itemListSheet.getCell(`A${i + 1}`).value = m.itemCode; itemListSheet.getCell(`B${i + 1}`).value = m.itemName; }); } // ═══════════════════ 3. 검사시트별 생성 ═══════════════════ for (const sheetConfig of config.sheets) { const ws = workbook.addWorksheet(sheetConfig.name); // 컬럼 너비 sheetConfig.columns.forEach((col, i) => { ws.getColumn(i + 1).width = col.width || 18; }); // 헤더 행 const headerRow = ws.addRow(sheetConfig.columns.map((c) => c.label)); headerRow.height = 28; headerRow.eachCell((cell, colNumber) => { const col = sheetConfig.columns[colNumber - 1]; cell.font = { bold: true, size: 10, color: { argb: "FF1E293B" } }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } }; cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } }; cell.alignment = { vertical: "middle", horizontal: "center" }; if (col?.required) { cell.font = { bold: true, size: 10, color: { argb: "FFDC2626" } }; } if (col?.readOnly || col?.autoFill) { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE2E8F0" } }; } }); // ═══════ 컬럼 범위 기반 data validation + 수식 (행 제한 없음) ═══════ // 행 2부터 10000까지 범위 설정 (셀 루프 대신 범위 단위) // 수식 행 수: 성능과 사용성 균형 (드롭다운 validation은 범위라 무제한) const FORMULA_END = 2000; const VALIDATION_END = 65000; for (let colIdx = 0; colIdx < sheetConfig.columns.length; colIdx++) { const col = sheetConfig.columns[colIdx]; const colL = colLetter(colIdx + 1); const rangeStr = `${colL}2:${colL}${VALIDATION_END}`; // ── 수식 컬럼: 2행에만 수식 세팅 (사용자가 아래로 복사하거나 테이블 확장) ── if (col.customFormula) { let formula = col.customFormula; formula = formula.replace(/\{col:(\w+)\}/g, (_, key) => { const idx = sheetConfig.columns.findIndex(c => c.key === key); return idx >= 0 ? `${colLetter(idx + 1)}2` : `"?"`; }); formula = formula.replace(/\{itemCount\}/g, String(itemProcessMappings?.length || 9999)); // 2행~FORMULA_END 행에 수식 삽입 (잠금 + 회색 배경) for (let r = 2; r <= FORMULA_END; r++) { // 상대참조만 치환 (A2→A{r}), 절대참조($A$2)는 유지 const rowFormula = formula.replace(/(? `${c}${r}`); const cell = ws.getCell(`${colL}${r}`); cell.value = { formula: rowFormula } as any; cell.protection = { locked: true }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } }; cell.font = { size: 10, color: { argb: "FF64748B" } }; cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } }; } continue; } if (col.autoFill && refSheet && refCols.length > 0) { const lookupColIdx = sheetConfig.columns.findIndex(c => c.key === col.autoFill!.lookupColumn); const refColIdx = refCols.findIndex(c => c.key === col.autoFill!.referenceColumn) + 1; if (lookupColIdx >= 0 && refColIdx > 0) { const refRange = `'${config.referenceSheet!.name}'!$A$2:$${colLetter(refCols.length)}$${refDataRows.length + 1}`; for (let r = 2; r <= FORMULA_END; r++) { const lookupCell = `${colLetter(lookupColIdx + 1)}${r}`; const cell = ws.getCell(`${colL}${r}`); cell.value = { formula: `IFERROR(VLOOKUP(${lookupCell},${refRange},${refColIdx},FALSE),"")` } as any; cell.protection = { locked: true }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF8FAFC" } }; cell.font = { size: 10, color: { argb: "FF64748B" } }; cell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } }; } } continue; } // ── data validation: 범위 단위로 한 번에 설정 ── // enableWhen (참조시트 직접 조회) if (col.enableWhen && refSheet && refCols.length > 0 && refDataRows.length > 0) { const condRefColIdx = refCols.findIndex(c => c.key === col.enableWhen!.column); const lookupKeyColIdx = sheetConfig.columns.findIndex(c => c.autoFill?.referenceColumn === col.enableWhen!.column); const lookupSourceColIdx = lookupKeyColIdx >= 0 ? sheetConfig.columns.findIndex(c => c.key === sheetConfig.columns[lookupKeyColIdx].autoFill?.lookupColumn) : -1; if (condRefColIdx >= 0 && lookupSourceColIdx >= 0) { const lookupColL = colLetter(lookupSourceColIdx + 1); const refRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`; const refValueRange = `'${config.referenceSheet!.name}'!$${colLetter(condRefColIdx + 1)}$2:$${colLetter(condRefColIdx + 1)}$${refDataRows.length + 1}`; const keyword = col.enableWhen.equals.replace(/[()]/g, "").slice(0, 2); const condColLabel = sheetConfig.columns.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column; // 상대 참조 (2행 기준, 범위 적용 시 자동 조정) (ws as any).dataValidations.add(rangeStr, { type: "custom", allowBlank: true, formulae: [`ISNUMBER(SEARCH("${keyword}",IFERROR(INDEX(${refValueRange},MATCH(${lookupColL}2,${refRange},0)),"")))`], showErrorMessage: true, errorStyle: "stop", errorTitle: "입력 불가", error: `${condColLabel}이(가) "${col.enableWhen.equals}"일 때만 입력 가능합니다`, }); } continue; } // INDIRECT 동적 드롭다운 if (col.dropdown?.source === "indirect") { const prefix = col.dropdown.indirectPrefix || "P_"; const keyColName = col.dropdown.indirectKeyColumn; if (!keyColName) continue; const keyColIdx = sheetConfig.columns.findIndex(c => c.key === keyColName); if (keyColIdx >= 0) { const keyColL = colLetter(keyColIdx + 1); if (prefix === "ACC_" && refDataRows.length > 0) { const refNameRange = `'${config.referenceSheet!.name}'!$A$2:$A$${refDataRows.length + 1}`; (ws as any).dataValidations.add(rangeStr, { type: "list", allowBlank: true, formulae: [`INDIRECT("ACC_"&MATCH(${keyColL}2,${refNameRange},0))`], showErrorMessage: false, }); } else { // sanitizeNameForExcel과 동일한 치환: 비영숫자 → _ const sanitizeFormula = `SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(${keyColL}2,"-","_"),".","_")," ","_"),"/","_"),"(","_"),")","_"),"#","_")`; (ws as any).dataValidations.add(rangeStr, { type: "list", allowBlank: true, formulae: [`INDIRECT("${prefix}"&${sanitizeFormula})`], showErrorMessage: false, }); } } continue; } // 일반 드롭다운 if (col.type === "dropdown") { const optionKey = `${sheetConfig.name}:${col.key}`; const globalKey = col.key; const configValues = col.dropdown?.values; const values = (configValues && configValues.length > 0 ? configValues : null) || dropdownOptions?.[optionKey] || dropdownOptions?.[globalKey] || []; if (values.length > 0) { // 품목목록 시트 참조 (품목명 등 대량 드롭다운) const isItemNameDropdown = hasItemProcess && values.length === itemProcessMappings!.length && values[0] === itemProcessMappings![0]?.itemName; if (isItemNameDropdown) { (ws as any).dataValidations.add(rangeStr, { type: "list", allowBlank: !col.required, formulae: [`'_품목목록'!$B$1:$B$${itemProcessMappings!.length}`], showErrorMessage: true, errorStyle: "stop", errorTitle: "입력 오류", error: "품목 목록에서 선택해주세요", }); } else { const joined = `"${values.join(",")}"`; if (joined.length <= 255) { (ws as any).dataValidations.add(rangeStr, { type: "list", allowBlank: !col.required, formulae: [joined], showErrorMessage: true, errorStyle: "stop", errorTitle: "입력 오류", error: `다음 중 선택: ${values.slice(0, 5).join(", ")}${values.length > 5 ? " ..." : ""}`, }); } else { const listSheetName = `_list_${sheetConfig.name}_${col.key}`.slice(0, 31); let listSheet = workbook.getWorksheet(listSheetName); if (!listSheet) { listSheet = workbook.addWorksheet(listSheetName, { state: "veryHidden" }); values.forEach((v, i) => { listSheet!.getCell(`A${i + 1}`).value = v; }); } (ws as any).dataValidations.add(rangeStr, { type: "list", allowBlank: !col.required, formulae: [`'${listSheetName}'!$A$1:$A$${values.length}`], showErrorMessage: true, errorStyle: "stop", errorTitle: "입력 오류", error: "목록에서 선택해주세요", }); } } } continue; } // 숫자 포맷 if (col.type === "number") { ws.getColumn(colIdx + 1).numFmt = "#,##0.##"; } } // 시트 보호 — 수식/자동입력 컬럼 잠금, 편집 컬럼 해제 const hasLockedCols = sheetConfig.columns.some(c => c.autoFill || c.readOnly || c.customFormula); if (hasLockedCols) { // Excel 기본: 모든 셀 locked=true → 편집 가능 컬럼만 unlocked 처리 const editableColIndices: number[] = []; sheetConfig.columns.forEach((c, ci) => { if (!c.autoFill && !c.readOnly && !c.customFormula) { editableColIndices.push(ci); } }); // 편집 가능 컬럼만 unlock (FORMULA_END 행까지) for (const ci of editableColIndices) { const cl = colLetter(ci + 1); for (let r = 2; r <= FORMULA_END; r++) { const editCell = ws.getCell(`${cl}${r}`); editCell.protection = { locked: false }; editCell.border = { top: { style: "hair", color: { argb: "FFE2E8F0" } }, bottom: { style: "hair", color: { argb: "FFE2E8F0" } }, left: { style: "hair", color: { argb: "FFE2E8F0" } }, right: { style: "hair", color: { argb: "FFE2E8F0" } } }; } } ws.protect("", { selectLockedCells: true, selectUnlockedCells: true, formatCells: true, sort: true, autoFilter: true, }); } // 고정틀 ws.views = [{ state: "frozen", ySplit: 1, xSplit: 0 }]; } // ═══════════════════ 4. 안내 시트 ═══════════════════ const guideSheet = workbook.addWorksheet("안내"); guideSheet.getColumn(1).width = 20; guideSheet.getColumn(2).width = 50; // Config 기반으로 안내시트 자동 생성 const firstSheetCols = config.sheets[0]?.columns || []; const lockedCols = firstSheetCols.filter(c => c.autoFill || c.readOnly || c.customFormula).map(c => c.label); const editableCols = firstSheetCols.filter(c => !c.autoFill && !c.readOnly && !c.customFormula); const guideData: [string, string][] = [ ["템플릿 정보", ""], ["파일명", config.templateName], ["생성일시", new Date().toLocaleString("ko-KR")], ...(hasItemProcess ? [["품목 수", `${itemProcessMappings!.length}건`] as [string, string]] : []), ["시트 구성", config.sheets.map(s => s.name).join(", ")], ["", ""], ["컬럼 설명", ""], // 컬럼별 자동 설명 생성 ...firstSheetCols.map((col): [string, string] => { const parts: string[] = []; if (col.required) parts.push("필수"); if (col.autoFill) parts.push(`${firstSheetCols.find(c => c.key === col.autoFill!.lookupColumn)?.label || ""} 선택 시 자동 입력 (수정 불가)`); else if (col.customFormula) parts.push("자동 입력 (수정 불가)"); else if (col.readOnly) parts.push("읽기전용"); else if (col.dropdown?.source === "indirect") parts.push("연동 드롭다운 (관련 컬럼 선택 후 목록 표시)"); else if (col.type === "dropdown") parts.push("드롭다운에서 선택"); else if (col.type === "number") parts.push("숫자 입력"); else parts.push("텍스트 입력"); if (col.enableWhen) parts.push(`${firstSheetCols.find(c => c.key === col.enableWhen!.column)?.label || col.enableWhen!.column}이(가) "${col.enableWhen.equals}"일 때만 입력 가능`); return [col.label, parts.join(" — ")]; }), ["", ""], // 조건부 규칙이 있으면 자동 설명 ...(config.conditionalRules && config.conditionalRules.length > 0 ? [ ["조건별 입력 규칙", ""] as [string, string], ...config.conditionalRules.map((rule): [string, string] => { const reqLabels = rule.require.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", "); const ignLabels = rule.ignore.map(k => firstSheetCols.find(c => c.key === k)?.label || k).join(", "); const condLabel = firstSheetCols.find(c => c.key === rule.when.column)?.label || rule.when.column; return [rule.when.equals, `${condLabel}이(가) "${rule.when.equals}"일 때: ${reqLabels} 필수${ignLabels ? `, ${ignLabels} 무시` : ""}`]; }), ["", ""] as [string, string], ] : []), ["사용 방법", ""], ["1단계", `하단의 시트(${config.sheets.map(s => s.name).join(", ")})로 이동`], ...editableCols.slice(0, 3).map((col, i): [string, string] => [`${i + 2}단계`, `${col.label}${col.type === "dropdown" ? "을(를) 드롭다운에서 선택" : "을(를) 입력"}`] ), [`${Math.min(editableCols.length, 3) + 2}단계`, "필요한 시트만 작성 (사용하지 않는 시트는 비워두세요)"], [`${Math.min(editableCols.length, 3) + 3}단계`, "작성 완료 후 시스템에서 파일 업로드"], ["", ""], ["주의사항", ""], ...(lockedCols.length > 0 ? [[ "1", `잠금된 셀(${lockedCols.join(", ")})은 자동 입력됩니다. 직접 수정하지 마세요.` ] as [string, string]] : []), ["2", "사용하지 않는 시트는 비워두면 자동으로 무시됩니다."], ["3", "기준 데이터가 변경된 경우 템플릿을 다시 다운로드해주세요."], ["4", "업로드 시 입력 데이터를 자동 검증합니다. 오류가 있으면 상세 내용이 표시됩니다."], ]; guideData.forEach(([a, b]) => { const row = guideSheet.addRow([a, b]); if (["템플릿 정보", "컬럼 설명", "사용 방법", "조건별 입력 규칙", "주의사항"].includes(a)) { row.eachCell((cell) => { cell.font = { bold: true, size: 12, color: { argb: "FF1E40AF" } }; }); } }); // ═══════════════════ 5. _meta 시트 (숨김) ═══════════════════ const metaSheet = workbook.addWorksheet("_meta", { state: "veryHidden" }); metaSheet.getColumn(1).width = 20; metaSheet.getColumn(2).width = 50; const metaEntries: [string, string][] = [ ["version_hash", versionHash], ["created_at", new Date().toISOString()], ["template_name", config.templateName], ["item_count", hasItemProcess ? String(itemProcessMappings!.length) : "0"], ...(Object.entries(extraMeta || {}) as [string, string][]), ...(Object.entries(config.extraMeta || {}) as [string, string][]), ]; metaEntries.forEach(([k, v]) => metaSheet.addRow([k, v])); // ═══════════════════ 6. 시트 순서 조정 ═══════════════════ const guideIdx = workbook.worksheets.findIndex((ws) => ws.name === "안내"); if (guideIdx > 0) { (guideSheet as any).orderNo = 0; let order = 1; for (const ws of workbook.worksheets) { if (ws.name !== "안내") { (ws as any).orderNo = order++; } } } const buffer = await workbook.xlsx.writeBuffer(); return buffer; } /** 현재 DB 데이터 기반 해시 재생성 (업로드 검증용) */ export function regenerateHash( referenceData: Record[], dropdownOptions: Record, itemProcessMappings?: ItemProcessMapping[] ): string { const hashSource = JSON.stringify({ referenceData, dropdownOptions, itemProcessMappings: itemProcessMappings?.map(m => ({ itemCode: m.itemCode, processes: m.processes.map(p => p.name), })), }); return generateHash(hashSource); }