Files
vexplor/frontend/components/common/SmartExcelUpload/templateGenerator.ts
kjs a89e99560d feat: Enhance Excel template generation with error handling improvements
- Added errorStyle set to "stop" for various input validations in the template generation process.
- This enhancement ensures that users receive clear error messages when input criteria are not met, improving the overall user experience during data entry.
- These changes aim to streamline the Excel upload process and enhance data integrity across multiple company implementations.
2026-04-15 16:02:11 +09:00

533 lines
24 KiB
TypeScript

/**
* 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,
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<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);
}