/** * SmartExcelUpload — 템플릿 파서 * 업로드된 엑셀 파일 파싱 + 해시 검증 + 데이터 검증 */ import ExcelJS from "exceljs"; import type { SmartExcelUploadConfig, ParseResult, ParsedSheetData, ValidationError, ConditionalRule, ItemProcessMapping, } from "./types"; import { regenerateHash } from "./templateGenerator"; export interface ParseOptions { config: SmartExcelUploadConfig; file: File; /** 현재 DB의 참조 데이터 (해시 검증용) */ currentReferenceData: Record[]; /** 현재 DB의 드롭다운 옵션 (해시 검증용) */ currentDropdownOptions: Record; /** 현재 DB의 품목공정 매핑 (해시 검증용) */ currentItemProcessMappings?: ItemProcessMapping[]; /** 참조 데이터 매핑 (라벨→코드 변환 등) */ labelToCodeMap?: Record>; } /** 셀 값을 문자열로 안전 변환 */ function cellToString(cell: ExcelJS.Cell): string { const val = cell.value; if (val === null || val === undefined) return ""; // 수식 객체 if (typeof val === "object" && "formula" in val) { if ("result" in val) { return String((val as any).result ?? ""); } // 수식만 있고 결과 없음 (미계산) → 빈 문자열 return ""; } // RichText if (typeof val === "object" && "richText" in val) { return (val as any).richText.map((r: any) => r.text).join(""); } return String(val).trim(); } /** _meta 시트에서 메타 정보 읽기 */ function readMeta( workbook: ExcelJS.Workbook ): Record | null { const metaSheet = workbook.getWorksheet("_meta"); if (!metaSheet) return null; const meta: Record = {}; metaSheet.eachRow((row) => { const key = cellToString(row.getCell(1)); const value = cellToString(row.getCell(2)); if (key) meta[key] = value; }); return meta; } /** 엑셀 파일 파싱 + 검증 */ export async function parseTemplate( options: ParseOptions ): Promise { const { config, file, currentReferenceData, currentDropdownOptions, currentItemProcessMappings, labelToCodeMap, } = options; const errors: ValidationError[] = []; const warnings: string[] = []; const data: ParsedSheetData[] = []; // 파일 읽기 const arrayBuffer = await file.arrayBuffer(); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(arrayBuffer); // ═══════════════════ 1. 메타 검증 ═══════════════════ const meta = readMeta(workbook); if (!meta) { return { success: false, data: [], errors: [], warnings: [ "공식 템플릿이 아닙니다. 템플릿을 다운로드 후 사용해주세요.", ], }; } // 해시 검증 const currentHash = regenerateHash( currentReferenceData, currentDropdownOptions, currentItemProcessMappings ); if (meta.version_hash && meta.version_hash !== currentHash) { return { success: false, data: [], errors: [], warnings: [ "기준 데이터가 변경되었습니다. 최신 템플릿을 다시 다운로드해주세요.", ], }; } // ═══════════════════ 2. 시트별 파싱 ═══════════════════ for (const sheetConfig of config.sheets) { const ws = workbook.getWorksheet(sheetConfig.name); if (!ws) continue; const rows: Record[] = []; // 헤더 확인 (1행) const headerRow = ws.getRow(1); const colMap: Record = {}; headerRow.eachCell((cell, colNumber) => { const label = cellToString(cell); const col = sheetConfig.columns.find((c) => c.label === label); if (col) colMap[colNumber] = col.key; }); // 사용자 입력 컬럼만 추출 (autoFill/readOnly/customFormula 제외) const userInputKeys = new Set( sheetConfig.columns .filter((c) => !c.readOnly && !c.autoFill && !c.customFormula) .map((c) => c.key) ); // 데이터 행 파싱 (2행부터) ws.eachRow((row, rowNumber) => { if (rowNumber <= 1) return; // 빈 행 체크: 사용자 입력 컬럼만 확인 (수식 셀 무시) let hasData = false; for (const [colNum, key] of Object.entries(colMap)) { if (!userInputKeys.has(key)) continue; const val = cellToString(row.getCell(Number(colNum))); if (val) { hasData = true; break; } } if (!hasData) return; const rowData: Record = {}; for (const [colNum, key] of Object.entries(colMap)) { let value = cellToString(row.getCell(Number(colNum))); // 라벨→코드 변환 if (labelToCodeMap?.[key] && value) { const code = labelToCodeMap[key][value]; if (code) { rowData[`${key}_label`] = value; value = code; } } rowData[key] = value; } // ── 행별 검증 ── // 필수값 검증 for (const col of sheetConfig.columns) { if (col.readOnly || col.autoFill || col.customFormula) continue; const value = rowData[col.key]; const isEmpty = !value && value !== 0; // 조건부 규칙 적용 // when.column이 autoFill/수식 컬럼이면 값이 미계산일 수 있으므로 // labelToCodeMap 역매핑 또는 참조데이터에서 직접 조회 if (config.conditionalRules) { const resolveCondValue = (colKey: string): string => { // 직접 입력된 값이 있으면 사용 if (rowData[colKey]) return rowData[colKey]; // autoFill 컬럼이면: lookup 기준 컬럼의 값으로 참조데이터에서 조회 const colDef = sheetConfig.columns.find(c => c.key === colKey); if (colDef?.autoFill && currentReferenceData.length > 0) { const lookupVal = rowData[colDef.autoFill.lookupColumn]; if (lookupVal) { const refRow = currentReferenceData.find(r => r[config.referenceSheet?.columns?.[0]?.key || "label"] === lookupVal ); if (refRow) return refRow[colDef.autoFill.referenceColumn] || ""; } } return ""; }; const applicableRules = config.conditionalRules.filter( (rule) => resolveCondValue(rule.when.column) === rule.when.equals ); for (const rule of applicableRules) { // 필수 컬럼 체크 if (rule.require.includes(col.key) && isEmpty) { const condLabel = sheetConfig.columns.find(c => c.key === rule.when.column)?.label || rule.when.column; errors.push({ sheet: sheetConfig.name, row: rowNumber, column: col.label, message: `${condLabel}이(가) "${rule.when.equals}"일 때 ${col.label}은(는) 필수입니다`, }); } } // 무시 컬럼이면 검증 스킵 const isIgnored = applicableRules.some((rule) => rule.ignore.includes(col.key) ); if (isIgnored) continue; } // 일반 필수값 검증 if (col.required && isEmpty) { errors.push({ sheet: sheetConfig.name, row: rowNumber, column: col.label, message: `${col.label}은(는) 필수 항목입니다`, }); } } // 드롭다운 값 유효성 검증 for (const col of sheetConfig.columns) { if (col.type !== "dropdown" || col.readOnly || col.autoFill) continue; const value = rowData[col.key]; if (!value) continue; const optionKey = `${sheetConfig.name}:${col.key}`; const globalKey = col.key; const validValues = col.dropdown?.values || currentDropdownOptions[optionKey] || currentDropdownOptions[globalKey] || []; if (validValues.length > 0 && !validValues.includes(value)) { // 라벨로 입력한 경우도 체크 const labelValue = rowData[`${col.key}_label`] || value; if (!validValues.includes(labelValue)) { errors.push({ sheet: sheetConfig.name, row: rowNumber, column: col.label, message: `유효하지 않은 값: "${value}". 가능한 값: ${validValues.slice(0, 5).join(", ")}${validValues.length > 5 ? " ..." : ""}`, }); } } } // INDIRECT 드롭다운 유효성 검증 (품목별 공정 등) if (currentItemProcessMappings && currentItemProcessMappings.length > 0) { for (const col of sheetConfig.columns) { if (col.dropdown?.source !== "indirect" || col.dropdown.indirectPrefix === "ACC_") continue; const value = rowData[col.key]; if (!value) continue; // 품목코드 resolve const keyColName = col.dropdown.indirectKeyColumn || ""; let itemCode = rowData[keyColName] || ""; // customFormula 컬럼이면 수식 결과가 없을 수 있음 → 품목명으로 역매핑 if (!itemCode) { // 사용자가 입력한 컬럼 중 itemProcessMappings의 itemName과 매칭되는 값 찾기 for (const [, val] of Object.entries(rowData)) { if (val && typeof val === "string") { const mapping = currentItemProcessMappings.find(m => m.itemName === val); if (mapping) { itemCode = mapping.itemCode; break; } } } } if (itemCode) { const mapping = currentItemProcessMappings.find(m => m.itemCode === itemCode); if (mapping) { const valid = mapping.processes.some(p => p.code === value || p.name === value); if (!valid) { const displayValue = rowData[`${col.key}_label`] || mapping.processes.find(p => p.code === value)?.name || value; const validNames = mapping.processes.map(p => p.name); errors.push({ sheet: sheetConfig.name, row: rowNumber, column: col.label, message: `"${displayValue}"은(는) 해당 품목의 유효한 공정이 아닙니다${validNames.length > 0 ? `. 가능한 공정: ${validNames.join(", ")}` : ""}`, }); } } } } } rows.push(rowData); }); if (rows.length > 0) { data.push({ sheetName: sheetConfig.name, typeKey: sheetConfig.typeKey, rows, }); } } // 데이터 없음 체크 if (data.length === 0 || data.every((d) => d.rows.length === 0)) { warnings.push("업로드할 데이터가 없습니다. 시트에 데이터를 입력해주세요."); } return { success: errors.length === 0 && warnings.length === 0, data, errors, warnings, }; }