Files
vexplor/frontend/components/common/SmartExcelUpload/templateParser.ts
kjs dffa16f3e5 feat: Add Smart Excel Upload functionality for item inspection
- Introduced a new SmartExcelUploadModal component to facilitate bulk item inspection uploads via Excel.
- Implemented logic for downloading templates, validating uploaded files, and parsing data for inspection criteria.
- Enhanced the item inspection page to support dynamic loading of item process mappings and reference data for improved user experience.
- Added necessary types and utility functions for template generation and parsing, ensuring robust handling of Excel data.
- These changes aim to streamline the item inspection process and improve data management across multiple company implementations.
2026-04-15 14:23:44 +09:00

328 lines
11 KiB
TypeScript

/**
* 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<string, any>[];
/** 현재 DB의 드롭다운 옵션 (해시 검증용) */
currentDropdownOptions: Record<string, string[]>;
/** 현재 DB의 품목공정 매핑 (해시 검증용) */
currentItemProcessMappings?: ItemProcessMapping[];
/** 참조 데이터 매핑 (라벨→코드 변환 등) */
labelToCodeMap?: Record<string, Record<string, string>>;
}
/** 셀 값을 문자열로 안전 변환 */
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<string, string> | null {
const metaSheet = workbook.getWorksheet("_meta");
if (!metaSheet) return null;
const meta: Record<string, string> = {};
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<ParseResult> {
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<string, any>[] = [];
// 헤더 확인 (1행)
const headerRow = ws.getRow(1);
const colMap: Record<number, string> = {};
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<string, any> = {};
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,
};
}