- 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.
533 lines
24 KiB
TypeScript
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);
|
|
}
|