- 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.
328 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|