2026-04-15 14:23:44 +09:00
/ * *
* 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 < string , any > [ ] ;
/** 시트별 드롭다운 옵션 (카테고리 값 등) */
dropdownOptions? : Record < string , string [ ] > ;
/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */
itemProcessMappings? : ItemProcessMapping [ ] ;
/** 추가 메타 정보 */
extraMeta? : Record < string , string > ;
}
export async function generateTemplate (
options : GenerateTemplateOptions
) : Promise < ExcelJS.Buffer > {
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 ( /(?<!\$)([A-Z]+)(?<!\$)2(?![0-9])/g , ( match , c ) = > ` ${ 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 ,
2026-04-15 16:02:11 +09:00
errorStyle : "stop" ,
2026-04-15 14:23:44 +09:00
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 ,
2026-04-15 16:02:11 +09:00
errorStyle : "stop" ,
2026-04-15 14:23:44 +09:00
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 ,
2026-04-15 16:02:11 +09:00
errorStyle : "stop" ,
2026-04-15 14:23:44 +09:00
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 ,
2026-04-15 16:02:11 +09:00
errorStyle : "stop" ,
2026-04-15 14:23:44 +09:00
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 < string , any > [ ] ,
dropdownOptions : Record < string , string [ ] > ,
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 ) ;
}