From dffa16f3e5246dd325942520103e329e36abcb2e Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 15 Apr 2026 14:23:44 +0900 Subject: [PATCH] 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. --- .../controllers/workInstructionController.ts | 63 ++ .../src/routes/workInstructionRoutes.ts | 3 + docs/smart-excel-upload.md | 337 ++++++++++ .../quality/item-inspection/page.tsx | 259 +++++++- .../SmartExcelUploadModal.tsx | 589 ++++++++++++++++++ .../common/SmartExcelUpload/index.ts | 16 + .../SmartExcelUpload/templateGenerator.ts | 528 ++++++++++++++++ .../common/SmartExcelUpload/templateParser.ts | 327 ++++++++++ .../common/SmartExcelUpload/types.ts | 151 +++++ 9 files changed, 2271 insertions(+), 2 deletions(-) create mode 100644 docs/smart-excel-upload.md create mode 100644 frontend/components/common/SmartExcelUpload/SmartExcelUploadModal.tsx create mode 100644 frontend/components/common/SmartExcelUpload/index.ts create mode 100644 frontend/components/common/SmartExcelUpload/templateGenerator.ts create mode 100644 frontend/components/common/SmartExcelUpload/templateParser.ts create mode 100644 frontend/components/common/SmartExcelUpload/types.ts diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index b8960006..9d3341d2 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -372,6 +372,69 @@ export async function getRoutingVersions(req: AuthenticatedRequest, res: Respons } } +// ─── 품목별 라우팅 벌크 조회 (엑셀 업로드용) ─── +export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCodes } = req.body as { itemCodes: string[] }; + + if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) { + return res.json({ success: true, data: {} }); + } + + const pool = getPool(); + const result: Record = {}; + + // 청크 단위로 분할 (PostgreSQL placeholder 제한 대응) + const CHUNK_SIZE = 5000; + for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) { + const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE); + + // 1. 기본 라우팅 버전 조회 + const placeholders = chunk.map((_, i) => `$${i + 2}`).join(","); + const versionsResult = await pool.query( + `SELECT DISTINCT ON (item_code) id, item_code, version_name + FROM item_routing_version + WHERE company_code = $1 AND item_code IN (${placeholders}) + ORDER BY item_code, is_default DESC, created_date DESC`, + [companyCode, ...chunk] + ); + + if (versionsResult.rows.length === 0) continue; + + // 2. 라우팅 디테일 조회 + const versionIds = versionsResult.rows.map((v: any) => v.id); + const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(","); + const detailsResult = await pool.query( + `SELECT rd.routing_version_id, rd.process_code, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders}) + ORDER BY rd.seq_no::integer`, + [companyCode, ...versionIds] + ); + + // 3. 매핑 + const versionToItem: Record = {}; + for (const v of versionsResult.rows) { + versionToItem[v.id] = v.item_code; + } + for (const d of detailsResult.rows) { + const itemCode = versionToItem[d.routing_version_id]; + if (!itemCode) continue; + if (!result[itemCode]) result[itemCode] = []; + result[itemCode].push({ code: d.process_code, name: d.process_name }); + } + } + + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("벌크 라우팅 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 작업지시 라우팅 변경 ─── export async function updateRouting(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index a65f6f54..07507b25 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -15,6 +15,9 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource); router.get("/equipment", ctrl.getEquipmentList); router.get("/employees", ctrl.getEmployeeList); +// 벌크 라우팅 조회 (품목별 공정 일괄 조회) +router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk); + // 라우팅 & 공정작업기준 router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); diff --git a/docs/smart-excel-upload.md b/docs/smart-excel-upload.md new file mode 100644 index 00000000..ab55c37b --- /dev/null +++ b/docs/smart-excel-upload.md @@ -0,0 +1,337 @@ +# SmartExcelUpload + +설정(Config) 기반 엑셀 업로드 공통 모듈. Config 객체와 데이터를 넘기면 템플릿 생성, 업로드, 검증, 미리보기까지 자동 처리된다. 화면별로 Config 정의 + 데이터 조회 + 저장 콜백만 작성하면 어디든 적용 가능. + +## 기존 ExcelUploadModal과의 차이 + +기존 `ExcelUploadModal`은 단일 테이블의 단순 데이터를 일괄 업로드하는 용도(거래처 목록 등). + +`SmartExcelUpload`는 아래와 같이 **기존 컴포넌트로 처리하기 어려운 복잡한 구조**에서 사용한다. + +| 상황 | 예시 | +|------|------| +| 셀 간 연동이 필요할 때 | A 컬럼 선택 → B 컬럼 자동 입력 | +| 선택한 값에 따라 드롭다운이 달라질 때 | 마스터 데이터별로 선택 가능한 하위 항목이 다름 | +| 조건에 따라 입력 가능/불가가 바뀔 때 | 특정 유형일 때만 특정 컬럼 입력 가능 | +| 멀티 시트로 유형을 구분할 때 | 유형별 시트 분리 | +| 참조 데이터 기반 자동 검증이 필요할 때 | 기준 데이터 변경 시 템플릿 재다운로드 유도 (해시 검증) | +| 1:N 관계의 데이터를 등록할 때 | 마스터 1개에 디테일 N개, 유형 다수 | + +단순 일괄 등록은 기존 `ExcelUploadModal`을 쓰면 된다. + +> **참고**: 셀 간 연동 수식(VLOOKUP, INDEX/MATCH 등)은 화면마다 다르다. SmartExcelUpload가 제공하는 것은 수식 자체가 아니라 **수식을 적용하는 메커니즘**(autoFill, customFormula, INDIRECT 등)이다. 어떤 컬럼에 어떤 수식이 들어갈지는 Config에서 화면별로 정의한다. + +## 파일 구조 + +``` +SmartExcelUpload/ + index.ts # export + types.ts # Config 인터페이스, 검증 타입 + templateGenerator.ts # ExcelJS 기반 엑셀 템플릿 생성 + templateParser.ts # 업로드 파일 파싱 + 해시 검증 + 데이터 검증 + SmartExcelUploadModal.tsx # 모달 UI (다운로드 → 업로드 → 검증 → 미리보기) +``` + +## 핵심 개념 + +### Config 기반 동작 + +모든 동작은 `SmartExcelUploadConfig`로 결정된다. + +```typescript +const config: SmartExcelUploadConfig = { + templateName: "파일명", + sheets: [...], // 시트 정의 (단일/멀티) + referenceSheet: {...}, // 참조 데이터 숨김시트 (선택) + conditionalRules: [...], // 조건부 검증 규칙 (선택) + indirectOptions: {...}, // ACC_ 동적 드롭다운 옵션 정의 (선택) +}; +``` + +### 엑셀 템플릿 구조 + +``` +[시트: 안내] ← Config 기반 자동 생성 (컬럼 설명, 입력 규칙, 사용법) +[시트: 데이터1] ← 사용자가 작성하는 시트 (여러 개 가능) +[시트: 데이터2] +[숨김: 참조시트] ← VLOOKUP 참조 데이터 (referenceSheet 설정 시) +[숨김: _품목공정] ← INDIRECT 이름 범위 (itemProcessMappings 설정 시) +[숨김: _품목목록] ← 마스터 드롭다운 소스 (itemProcessMappings 설정 시) +[숨김: _합격기준옵션] ← INDIRECT ACC_ 이름 범위 (indirectOptions 설정 시) +[숨김: _meta] ← 버전 해시, 생성일 +``` + +숨김시트들은 해당 기능을 사용하는 Config일 때만 생성된다. + +### 버전 해시 검증 + +- 템플릿 생성 시: 참조 데이터 + 드롭다운 옵션 + 매핑 데이터 → 해시 생성 → `_meta` 시트에 저장 +- 업로드 시: 현재 DB 데이터로 해시 재생성 → 일치 여부 확인 +- 불일치 시: "기준 데이터가 변경되었습니다. 최신 템플릿을 다시 다운로드해주세요" 경고 + +### 안내시트 자동 생성 + +Config의 컬럼 정의를 기반으로 안내시트가 자동 생성된다. + +- 컬럼별 설명 (필수 여부, 자동 입력 여부, 드롭다운 유형 등) +- 조건부 규칙 설명 (conditionalRules 기반) +- 잠금 셀 목록 (autoFill/readOnly/customFormula 컬럼) +- 사용 방법 단계 + +--- + +## 컬럼 타입 + +### 기본 타입 + +| type | 설명 | +|------|------| +| `text` | 자유 텍스트 | +| `number` | 숫자 (천단위 서식 자동 적용) | +| `date` | 날짜 | +| `dropdown` | 드롭다운 선택 | + +### 드롭다운 source 유형 + +| source | 설명 | 예시 | +|--------|------|------| +| `custom` | 고정 값 목록 | `values: ["Y", "N"]` | +| `category` | 카테고리 테이블 조회 | `tableName: "...", columnName: "..."` | +| `indirect` | 다른 셀 값에 따라 동적 변경 | `indirectKeyColumn: "...", indirectPrefix: "P_"` | + +### 컬럼 속성 + +| 속성 | 설명 | +|------|------| +| `required` | 필수 여부 — 업로드 검증 시 빈 값 체크 | +| `readOnly` | 읽기전용 — 셀 잠금 | +| `autoFill` | 참조시트에서 VLOOKUP 자동 입력 — 셀 잠금, 회색 배경 | +| `customFormula` | 커스텀 엑셀 수식 — `{col:key}` 플레이스홀더로 같은 행 참조 | +| `enableWhen` | 조건부 활성화 — 참조시트에서 직접 조회하여 판단 (VLOOKUP 미계산 문제 없음) | +| `disableWhen` | 조건부 비활성화 — 특정 조건일 때 입력 차단 | +| `width` | 컬럼 너비 (기본 18) | + +--- + +## 주요 기능 + +### 1. VLOOKUP 자동 입력 (autoFill) + +참조시트의 데이터를 기반으로 다른 셀 값에 연동되어 자동 입력된다. + +```typescript +{ + key: "detail_column", + label: "상세정보", + readOnly: true, + autoFill: { + lookupColumn: "master_key", // 같은 행의 이 컬럼 값을 기준으로 + referenceColumn: "detail", // 참조시트에서 이 컬럼 값을 가져옴 + } +} +``` + +### 2. 커스텀 수식 (customFormula) + +`{col:key}` 플레이스홀더를 사용하여 같은 행의 다른 컬럼을 참조하는 수식을 정의한다. 절대참조(`$`)는 행 치환에서 자동 보호된다. + +```typescript +{ + key: "code_column", + readOnly: true, + customFormula: `IFERROR(INDEX('_시트명'!$A$1:$A$9999,MATCH({col:name_column},'_시트명'!$B$1:$B$9999,0)),"")` +} +``` + +### 3. INDIRECT 동적 드롭다운 + +다른 셀 값에 따라 드롭다운 옵션이 동적으로 변경된다. + +**P_ prefix (이름 범위 직접 참조):** +```typescript +{ + key: "sub_item", + type: "dropdown", + dropdown: { + source: "indirect", + indirectKeyColumn: "master_code", // 이 컬럼 값을 기준으로 + indirectPrefix: "P_", // P_{값} 이름 범위 참조 + } +} +``` + +**ACC_ prefix (MATCH 인덱스 기반 참조):** +```typescript +{ + key: "criteria_value", + type: "dropdown", + dropdown: { + source: "indirect", + indirectKeyColumn: "standard_key", + indirectPrefix: "ACC_", // ACC_{인덱스} — MATCH로 인덱스 조회 + } +} +``` + +ACC_ prefix 사용 시 Config에 `indirectOptions` 설정 필요: +```typescript +indirectOptions: { + conditionColumn: "condition_type", // 참조시트에서 조건 판단할 컬럼 + optionsByCondition: { "타입A": ["O", "X"] }, // 조건값별 고정 옵션 + selectionOptionsColumn: "options_column", // 동적 옵션 (콤마 구분 문자열) +} +``` + +### 4. 조건부 활성화/비활성화 + +다른 컬럼 값에 따라 셀 입력 가능 여부가 결정된다. 참조시트에서 직접 조회하는 방식이라 VLOOKUP 미계산 문제가 없다. + +```typescript +{ key: "value_a", type: "number", enableWhen: { column: "condition_col", equals: "특정값" } } +``` + +### 5. 조건부 검증 규칙 + +업로드 시 특정 조건에 따라 필수/무시 컬럼이 달라진다. autoFill 컬럼의 값도 참조데이터에서 직접 조회하여 조건 판단. + +```typescript +conditionalRules: [ + { + when: { column: "condition_col", equals: "타입A" }, + require: ["required_col"], // 필수 + ignore: ["optional_col"], // 무시 + }, +] +``` + +### 6. ItemProcessMapping (마스터-디테일 매핑) + +마스터 항목별로 선택 가능한 하위 항목이 다를 때 사용. 벌크 API로 전체 데이터를 한 번에 조회하여 INDIRECT 이름 범위로 등록. + +```typescript +itemProcessMappings: [ + { itemCode: "M-001", itemName: "마스터A", processes: [{ code: "S01", name: "하위1" }] }, + { itemCode: "M-002", itemName: "마스터B", processes: [{ code: "S02", name: "하위2" }, { code: "S03", name: "하위3" }] }, +] +``` + +업로드 검증 시 마스터에 맞지 않는 하위 항목은 자동으로 에러 처리된다. + +--- + +## 성능 구조 + +| 항목 | 방식 | 범위 | +|------|------|------| +| 드롭다운/validation | **컬럼 범위 1회** 설정 | 65,000행 | +| 수식 (VLOOKUP, customFormula) | 행별 개별 삽입 | 2,000행 (FORMULA_END) | +| 셀 보호 (잠금/해제) | 행별 개별 설정 | 2,000행 | +| 셀 스타일 (배경, 테두리) | 행별 개별 설정 | 2,000행 | +| 데이터 캐싱 | 최초 로드 후 재사용 | 페이지 세션 | + +드롭다운은 범위 단위라 행 수 제한 없음. 수식/스타일은 `FORMULA_END` 상수로 조절 가능. + +--- + +## 사용법 + +### 1. 기본 사용 (단순 드롭다운만) + +```tsx +import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; +import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; + +const config: SmartExcelUploadConfig = { + templateName: "거래처", + sheets: [{ + name: "거래처", + columns: [ + { key: "name", label: "거래처명", required: true, type: "text", width: 24 }, + { key: "division", label: "구분", type: "dropdown", + dropdown: { source: "custom", values: ["매출처", "매입처"] } }, + ], + }], +}; + +const handleUpload = async (data: ParsedSheetData[]) => { + for (const sheet of data) { + for (const row of sheet.rows) await api.create(row); + } +}; + + +``` + +### 2. 고급 사용 (참조시트 + INDIRECT + 조건부 검증) + +```tsx + +``` + +### 3. Props + +| prop | 타입 | 필수 | 설명 | +|------|------|------|------| +| `open` | boolean | O | 모달 열림 상태 | +| `onOpenChange` | (open: boolean) => void | O | 모달 상태 변경 | +| `config` | SmartExcelUploadConfig | O | 전체 설정 | +| `referenceData` | Record[] | | 참조시트 데이터 | +| `dropdownOptions` | Record | | 드롭다운 옵션 (키: `시트명:컬럼key` 또는 `컬럼key`) | +| `itemProcessMappings` | ItemProcessMapping[] | | 마스터-디테일 매핑 데이터 | +| `labelToCodeMap` | Record> | | 라벨→코드 변환 | +| `extraMeta` | Record | | _meta 시트에 추가할 정보 | +| `onUpload` | (data: ParsedSheetData[]) => Promise | O | 업로드 완료 콜백 | +| `subtitle` | string | | 제목 아래 부가 설명 | +| `dataLoading` | boolean | | 외부 데이터 로딩 중 표시 | +| `loadProgress` | { loaded, total } | | 로딩 진행률 표시 | + +### 4. 벌크 조회 API (백엔드) + +마스터별 하위 항목을 한 번에 조회하는 API. 5,000건 단위 청크 분할로 대량 데이터 대응. + +``` +POST /work-instruction/routing-versions-bulk +Body: { itemCodes: ["M-001", "M-002", ...] } +Response: { success: true, data: { "M-001": [{ code, name }], "M-002": [...] } } +``` + +--- + +## 검증 흐름 + +``` +업로드 → 메타 해시 검증 → 시트별 파싱 (사용자 입력 컬럼만 빈 행 체크) + → 필수값 검증 (conditionalRules 적용, autoFill 값은 참조데이터에서 직접 조회) + → 드롭다운 유효성 검증 + → INDIRECT 매핑 검증 (마스터에 맞지 않는 하위 항목 에러) + → 에러 있으면 에러 리포트 / 없으면 미리보기 → 저장 +``` + +--- + +## 확장 시 참고 + +- 새 화면에 적용할 때: Config 정의 + 데이터 조회 + 저장 콜백만 작성 +- 단일 시트 / 멀티 시트 모두 지원 (`sheets` 배열 크기로 결정) +- 참조시트 필요 없으면 `referenceSheet` 생략 → 숨김시트 미생성 +- 조건부 검증 필요 없으면 `conditionalRules` 생략 → 단순 필수값 체크만 +- INDIRECT 필요 없으면 `itemProcessMappings` 생략 → 일반 드롭다운만 +- ACC_ 동적 드롭다운 필요 없으면 `indirectOptions` 생략 → 해당 시트 미생성 +- `FORMULA_END` (기본 2,000) / `VALIDATION_END` (기본 65,000)으로 범위 조절 가능 diff --git a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx index d3b008d7..b3f4e92b 100644 --- a/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -22,6 +22,8 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; +import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; const TABLE_NAME = "item_inspection_info"; const ITEM_TABLE = "item_info"; @@ -96,6 +98,9 @@ export default function ItemInspectionInfoPage() { // 기본 라우팅 공정 목록 (적용공정 Select용) const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 엑셀 업로드 모달 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -470,6 +475,227 @@ export default function ItemInspectionInfoPage() { } catch { toast.error("삭제에 실패했어요"); } }; + /* ═══════════════════ 엑셀 업로드 (다건 품목 모드) ═══════════════════ */ + const [excelItemProcessMappings, setExcelItemProcessMappings] = useState([]); + const [excelLoading, setExcelLoading] = useState(false); + const [excelLoadProgress, setExcelLoadProgress] = useState({ loaded: 0, total: 0 }); + + const openExcelUpload = async () => { + setExcelUploadOpen(true); + + // 캐시 히트: 이미 로드된 데이터 있으면 재사용 + if (excelItemProcessMappings.length > 0) return; + + setExcelLoading(true); + setExcelLoadProgress({ loaded: 0, total: 0 }); + + try { + // 1. 전체 품목 조회 + const itemRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 99999, autoFilter: true, + }); + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + setExcelLoadProgress({ loaded: items.length / 2, total: items.length }); + + // 2. 벌크 라우팅 조회 (1회 API 호출) + const itemCodes = items.map((item: any) => item.item_number || item.item_code || "").filter(Boolean); + let processMap: Record = {}; + try { + const bulkRes = await apiClient.post(`/work-instruction/routing-versions-bulk`, { itemCodes }); + if (bulkRes.data?.success) { + processMap = bulkRes.data.data || {}; + } + } catch { /* 벌크 API 실패 시 빈 공정으로 진행 */ } + + // 3. 매핑 구성 + const mappings: import("@/components/common/SmartExcelUpload").ItemProcessMapping[] = items.map((item: any) => { + const code = item.item_number || item.item_code || ""; + return { + itemCode: code, + itemName: item.item_name || "", + processes: processMap[code] || [], + }; + }); + + setExcelLoadProgress({ loaded: items.length, total: items.length }); + setExcelItemProcessMappings(mappings); + toast.success(`${mappings.length}개 품목 로드 완료`); + } catch { + toast.error("품목 정보 로드에 실패했습니다"); + } finally { + setExcelLoading(false); + } + }; + + // 엑셀 Config 생성 (다건 품목 모드) + const excelUploadConfig = useMemo((): SmartExcelUploadConfig => { + const itemCount = excelItemProcessMappings.length || 9999; + const makeColumns = () => [ + { key: "item_name", label: "품목명", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 }, + { key: "item_code", label: "품목코드", type: "text" as const, readOnly: true, customFormula: `IFERROR(INDEX('_품목목록'!$A$1:$A$${itemCount},MATCH({col:item_name},'_품목목록'!$B$1:$B$${itemCount},0)),"")`, width: 16 }, + { key: "inspection_standard", label: "검사기준", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 }, + { key: "inspection_detail", label: "검사기준 상세", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "detail" }, width: 20 }, + { key: "inspection_method", label: "검사방법", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "method" }, width: 14 }, + { key: "apply_process", label: "적용공정", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "item_code" }, width: 14 }, + { key: "judgment_criteria", label: "판단기준", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "judgment_criteria" }, width: 14 }, + { key: "standard_value", label: "기준값", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 12 }, + { key: "tolerance", label: "오차", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(범위)" }, width: 10 }, + { key: "unit", label: "단위", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "unit" }, width: 10 }, + { key: "acceptance_criteria", label: "합격기준", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "inspection_standard", indirectPrefix: "ACC_" }, width: 18 }, + { key: "is_required", label: "필수", type: "dropdown" as const, dropdown: { source: "custom" as const, values: ["Y", "N"] }, width: 8 }, + ]; + + return { + templateName: "품목검사정보", + sheets: INSPECTION_TYPES.map(t => ({ + name: t.label, + typeKey: t.label, + columns: makeColumns(), + })), + referenceSheet: { + name: "검사기준정보", + columns: [ + { key: "label", label: "검사기준명" }, + { key: "detail", label: "검사기준 상세" }, + { key: "method", label: "검사방법" }, + { key: "judgment_criteria", label: "판단기준" }, + { key: "selection_options", label: "선택옵션" }, + { key: "unit", label: "단위" }, + { key: "types", label: "검사유형" }, + ], + }, + conditionalRules: [ + { when: { column: "judgment_criteria", equals: "수치(범위)" }, require: ["standard_value"], ignore: ["acceptance_criteria"] }, + { when: { column: "judgment_criteria", equals: "O/X" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + { when: { column: "judgment_criteria", equals: "선택형" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + { when: { column: "judgment_criteria", equals: "텍스트입력" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + ], + indirectOptions: { + conditionColumn: "judgment_criteria", + optionsByCondition: { "O/X": ["O", "X"] }, + selectionOptionsColumn: "selection_options", + }, + }; + }, [excelItemProcessMappings]); + + // 참조 데이터 구성 + const excelReferenceData = useMemo(() => { + return inspOptions.map(opt => { + const methodLabel = inspMethodCatOptions.find(o => o.code === opt.method)?.label || opt.method; + const jcLabel = judgmentCatOptions.find(c => c.code === opt.judgment_criteria)?.label || opt.judgment_criteria; + const unitLabel = inspUnitCatOptions.find(c => c.code === opt.unit)?.label || opt.unit; + const typeLabels = opt.types.map(t => inspTypeCatOptions.find(c => c.code === t)?.label || t).join(","); + return { label: opt.label, detail: opt.detail, method: methodLabel, judgment_criteria: jcLabel, selection_options: opt.selection_options, unit: unitLabel, types: typeLabels }; + }); + }, [inspOptions, inspMethodCatOptions, judgmentCatOptions, inspUnitCatOptions, inspTypeCatOptions]); + + // 시트별 드롭다운 옵션 + const excelDropdownOptions = useMemo(() => { + const opts: Record = {}; + for (const t of INSPECTION_TYPES) { + const matchCodes = inspTypeCatOptions.filter(cat => t.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code); + const filtered = matchCodes.length > 0 + ? inspOptions.filter(opt => opt.types.some(tp => matchCodes.includes(tp))) + : inspOptions; + opts[`${t.label}:inspection_standard`] = filtered.map(o => o.label); + } + opts["is_required"] = ["Y", "N"]; + // 품목명 드롭다운 + opts["item_name"] = excelItemProcessMappings.map(m => m.itemName); + return opts; + }, [inspOptions, inspTypeCatOptions, excelItemProcessMappings]); + + // 라벨→코드 매핑 + const excelLabelToCodeMap = useMemo(() => { + const map: Record> = {}; + map["inspection_standard"] = {}; + for (const opt of inspOptions) map["inspection_standard"][opt.label] = opt.code; + // 품목명→품목코드 + map["item_name"] = {}; + for (const m of excelItemProcessMappings) map["item_name"][m.itemName] = m.itemCode; + // 적용공정 이름→코드 (전체 품목 공정에서) + map["apply_process"] = {}; + for (const m of excelItemProcessMappings) { + for (const p of m.processes) map["apply_process"][p.name] = p.code; + } + return map; + }, [inspOptions, excelItemProcessMappings]); + + // 엑셀 업로드 저장 (다건) + const handleExcelUpload = async (data: ParsedSheetData[]) => { + // 품목코드별로 그룹핑 + const itemCodeSet = new Set(); + const rows: any[] = []; + + for (const sheet of data) { + for (const row of sheet.rows) { + // 품목코드: 수식 결과 또는 품목명으로 역매핑 + let itemCode = row.item_code || ""; + const itemName = row.item_name || ""; + if (!itemCode && itemName) { + const mapping = excelItemProcessMappings.find(m => m.itemName === itemName); + if (mapping) itemCode = mapping.itemCode; + } + if (!itemCode) continue; + itemCodeSet.add(itemCode); + + const inspLabel = row.inspection_standard || ""; + const inspId = excelLabelToCodeMap["inspection_standard"]?.[inspLabel] || inspLabel; + const inspOpt = inspOptions.find(o => o.code === inspId); + const itemMapping = excelItemProcessMappings.find(m => m.itemCode === itemCode); + + let passCriteria = ""; + const jcLabel = inspOpt ? (judgmentCatOptions.find(c => c.code === inspOpt.judgment_criteria)?.label || inspOpt.judgment_criteria) : ""; + if (jcLabel === "수치(범위)") { + passCriteria = `${row.standard_value || ""}|${row.tolerance || ""}`; + } else { + passCriteria = row.acceptance_criteria || ""; + } + + // 적용공정 검증: 해당 품목의 유효 공정인지 확인 (품목 변경 후 공정 미초기화 대응) + let applyProcess = row.apply_process || ""; + if (applyProcess && itemMapping) { + const validProcess = itemMapping.processes.find(p => p.code === applyProcess || p.name === applyProcess); + if (!validProcess) { + applyProcess = ""; // 유효하지 않은 공정은 비움 + } + } + + rows.push({ + id: crypto.randomUUID(), + item_code: itemCode, + item_name: itemMapping?.itemName || itemCode, + inspection_type: sheet.typeKey || sheet.sheetName, + inspection_standard_id: inspId, + inspection_item_name: inspOpt?.detail || row.inspection_detail || "", + inspection_method: inspOpt?.method || "", + apply_process: applyProcess, + pass_criteria: passCriteria, + is_required: row.is_required === "Y" ? "true" : "false", + is_active: "사용", + }); + } + } + + // 해당 품목들의 기존 데이터 삭제 후 재등록 + for (const itemCode of itemCodeSet) { + const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 9999, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: itemCode }] }, + autoFilter: true, + }); + const existing = existRes.data?.data?.data || existRes.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); + } + } + + for (const row of rows) { + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); + } + fetchData(); + }; + /* ═══════════════════ JSX ═══════════════════ */ return (
@@ -500,6 +726,9 @@ export default function ItemInspectionInfoPage() {
+ @@ -655,7 +884,18 @@ export default function ItemInspectionInfoPage() { {row.inspection_item_name || "-"} {resolveInspLabel(row.inspection_standard_id)} {resolveMethodLabel(row.inspection_method)} - {row.apply_process || "-"} + {(() => { + const code = row.apply_process; + if (!code) return "-"; + // excelItemProcessMappings에서 공정명 찾기 + for (const m of excelItemProcessMappings) { + const proc = m.processes.find(p => p.code === code); + if (proc) return proc.name; + } + // processOptions (모달용)에서 찾기 + const proc = processOptions.find(p => p.code === code); + return proc?.name || code; + })()} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -930,6 +1170,21 @@ export default function ItemInspectionInfoPage() { + + {/* ═══════ 엑셀 업로드 모달 ═══════ */} +
); } diff --git a/frontend/components/common/SmartExcelUpload/SmartExcelUploadModal.tsx b/frontend/components/common/SmartExcelUpload/SmartExcelUploadModal.tsx new file mode 100644 index 00000000..6391e264 --- /dev/null +++ b/frontend/components/common/SmartExcelUpload/SmartExcelUploadModal.tsx @@ -0,0 +1,589 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Download, + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + XCircle, + Loader2, + ArrowRight, + Info, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import type { + SmartExcelUploadConfig, + ParseResult, + ParsedSheetData, + ValidationError, + ItemProcessMapping, +} from "./types"; +import { generateTemplate } from "./templateGenerator"; +import type { GenerateTemplateOptions } from "./templateGenerator"; +import { parseTemplate } from "./templateParser"; +import type { ParseOptions } from "./templateParser"; + +export interface SmartExcelUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: SmartExcelUploadConfig; + /** 참조 데이터 (DB에서 조회한 검사기준 등) */ + referenceData?: Record[]; + /** 시트별/컬럼별 드롭다운 옵션 */ + dropdownOptions?: Record; + /** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */ + itemProcessMappings?: ItemProcessMapping[]; + /** 라벨→코드 변환 매핑 */ + labelToCodeMap?: Record>; + /** 추가 메타 정보 (item_code 등) */ + extraMeta?: Record; + /** 업로드 완료 콜백 */ + onUpload: (data: ParsedSheetData[]) => Promise; + /** 품목명 등 표시용 제목 */ + subtitle?: string; + /** 데이터 로딩 중 여부 (외부에서 품목 등 로딩 시) */ + dataLoading?: boolean; + /** 로딩 진행률 */ + loadProgress?: { loaded: number; total: number }; +} + +type Step = "download" | "upload" | "validate" | "preview"; + +export function SmartExcelUploadModal({ + open, + onOpenChange, + config, + referenceData = [], + dropdownOptions = {}, + itemProcessMappings = [], + labelToCodeMap = {}, + extraMeta = {}, + onUpload, + subtitle, + dataLoading = false, + loadProgress, +}: SmartExcelUploadModalProps) { + const [step, setStep] = useState("download"); + const [downloading, setDownloading] = useState(false); + const [templateDownloaded, setTemplateDownloaded] = useState(false); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + const [parseResult, setParseResult] = useState(null); + const [previewTab, setPreviewTab] = useState(0); + const fileInputRef = useRef(null); + + const reset = useCallback(() => { + setStep("download"); + setTemplateDownloaded(false); + setParseResult(null); + setPreviewTab(0); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleClose = (open: boolean) => { + if (!open) reset(); + onOpenChange(open); + }; + + // ═══════════════════ 템플릿 다운로드 ═══════════════════ + const handleDownload = async () => { + setDownloading(true); + try { + const options: GenerateTemplateOptions = { + config, + referenceData, + dropdownOptions, + itemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined, + extraMeta, + }; + const buffer = await generateTemplate(options); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${config.templateName}_템플릿.xlsx`; + a.click(); + URL.revokeObjectURL(url); + + setTemplateDownloaded(true); + toast.success("템플릿 다운로드 완료"); + } catch (err) { + console.error("템플릿 생성 실패:", err); + toast.error("템플릿 생성에 실패했습니다"); + } finally { + setDownloading(false); + } + }; + + // ═══════════════════ 파일 업로드 + 파싱 ═══════════════════ + const handleFileChange = async ( + e: React.ChangeEvent + ) => { + const file = e.target.files?.[0]; + if (!file) return; + // 같은 파일 재선택 허용 + e.target.value = ""; + + if (!file.name.endsWith(".xlsx") && !file.name.endsWith(".xls")) { + toast.error("엑셀 파일(.xlsx)만 업로드 가능합니다"); + return; + } + + setUploading(true); + setStep("validate"); + setPreviewTab(0); + + try { + const parseOptions: ParseOptions = { + config, + file, + currentReferenceData: referenceData, + currentDropdownOptions: dropdownOptions, + currentItemProcessMappings: itemProcessMappings.length > 0 ? itemProcessMappings : undefined, + labelToCodeMap, + }; + const result = await parseTemplate(parseOptions); + setParseResult(result); + + if (result.success) { + setStep("preview"); + toast.success("검증 통과! 미리보기를 확인해주세요"); + } else if (result.warnings.length > 0 && result.errors.length === 0) { + // 경고만 있는 경우 + setStep("validate"); + } else { + setStep("validate"); + toast.error(`검증 실패: ${result.errors.length}건의 오류`); + } + } catch (err) { + console.error("파싱 실패:", err); + toast.error("파일 파싱에 실패했습니다"); + setStep("upload"); + } finally { + setUploading(false); + } + }; + + // ═══════════════════ 저장 ═══════════════════ + const handleSave = async () => { + if (!parseResult?.data?.length) return; + setSaving(true); + try { + await onUpload(parseResult.data); + toast.success("업로드 완료"); + handleClose(false); + } catch (err) { + console.error("저장 실패:", err); + toast.error("저장에 실패했습니다"); + } finally { + setSaving(false); + } + }; + + // ═══════════════════ 렌더링 ═══════════════════ + const totalRows = + parseResult?.data?.reduce((sum, d) => sum + d.rows.length, 0) || 0; + + return ( + + + + + + {config.templateName} 엑셀 업로드 + + + {subtitle || "템플릿을 다운로드하여 데이터를 작성한 후 업로드해주세요"} + + + + {/* 스텝 인디케이터 */} +
+ {( + [ + { key: "download", label: "템플릿 다운로드", icon: Download }, + { key: "upload", label: "파일 업로드", icon: Upload }, + { key: "validate", label: "검증", icon: AlertCircle }, + { key: "preview", label: "미리보기", icon: CheckCircle2 }, + ] as const + ).map(({ key, label, icon: Icon }, i) => { + const stepOrder = ["download", "upload", "validate", "preview"]; + const currentIdx = stepOrder.indexOf(step); + const thisIdx = stepOrder.indexOf(key); + const isActive = key === step; + const isDone = thisIdx < currentIdx; + return ( + + {i > 0 && ( + + )} +
+ + {label} +
+
+ ); + })} +
+ + {/* 컨텐츠 영역 */} +
+ {/* ── 다운로드 단계 ── */} + {(step === "download" || step === "upload") && ( +
+ {/* 데이터 로딩 진행률 */} + {dataLoading && loadProgress && loadProgress.total > 0 && ( +
+
+ + + 데이터 로딩 중... + + + {loadProgress.loaded.toLocaleString()} / {loadProgress.total.toLocaleString()} + +
+
+
+
+
+ )} + + {/* 템플릿 다운로드 */} +
+
+
+ + + 1. 템플릿 다운로드 + + {templateDownloaded && ( + + 완료 + + )} +
+ +
+
+

+ 시트 구성:{" "} + {config.sheets.map((s) => s.name).join(", ")} +

+ {config.referenceSheet && ( +

참조 데이터: {referenceData.length}건 포함

+ )} +
+
+ + {/* 파일 업로드 */} +
+
+ + + 2. 파일 업로드 + +
+
+ + {uploading && ( + + )} +
+ {dataLoading && ( +

+ + 데이터 로딩 중입니다. 잠시 기다려주세요 +

+ )} +
+
+ )} + + {/* ── 검증 결과 (에러) ── */} + {step === "validate" && parseResult && ( +
+ {/* 경고 메시지 */} + {parseResult.warnings.map((w, i) => ( +
+ + {w} +
+ ))} + + {/* 에러 목록 */} + {parseResult.errors.length > 0 && ( +
+
+ + + 검증 오류 {parseResult.errors.length}건 + +
+
+ + + + + 시트 + + + 행 + + + 컬럼 + + + 오류 내용 + + + + + {parseResult.errors.map((err, i) => ( + + + + {err.sheet} + + + + {err.row}행 + + + {err.column} + + + {err.message} + + + ))} + +
+
+
+ )} + +
+ +
+
+ )} + + {/* ── 미리보기 ── */} + {step === "preview" && parseResult?.data && ( +
+
+ + + 검증 통과 + + + 총 {totalRows}건 + +
+ + {/* 시트 탭 */} + {parseResult.data.length > 1 && ( +
+ {parseResult.data.map((sheet, i) => ( + + ))} +
+ )} + + {/* 데이터 테이블 */} + {parseResult.data[previewTab] && ( +
+ + + + + # + + {(() => { + const sheetData = parseResult.data[previewTab]; + const sheetConfig = config.sheets.find( + (s) => s.name === sheetData.sheetName + ); + return (sheetConfig?.columns || []) + .filter((c) => !c.readOnly && !c.autoFill && !c.customFormula) + .map((col) => ( + + {col.label} + + )); + })()} + + + + {parseResult.data[previewTab].rows.map((row, i) => { + const sheetData = parseResult.data[previewTab]; + const sheetConfig = config.sheets.find( + (s) => s.name === sheetData.sheetName + ); + return ( + + + {i + 1} + + {(sheetConfig?.columns || []) + .filter((c) => !c.readOnly && !c.autoFill && !c.customFormula) + .map((col) => ( + + {row[`${col.key}_label`] || + row[col.key] || + "-"} + + ))} + + ); + })} + +
+
+ )} +
+ )} +
+ + + + {step === "preview" && ( + + )} + + +
+ ); +} diff --git a/frontend/components/common/SmartExcelUpload/index.ts b/frontend/components/common/SmartExcelUpload/index.ts new file mode 100644 index 00000000..ecf717be --- /dev/null +++ b/frontend/components/common/SmartExcelUpload/index.ts @@ -0,0 +1,16 @@ +export { SmartExcelUploadModal } from "./SmartExcelUploadModal"; +export type { + SmartExcelUploadConfig, + SheetConfig, + SmartColumn, + ReferenceSheetConfig, + ConditionalRule, + DropdownConfig, + ItemProcessMapping, + IndirectOptionsConfig, + ParsedSheetData, + ParseResult, + ValidationError, +} from "./types"; +export { generateTemplate, regenerateHash } from "./templateGenerator"; +export { parseTemplate } from "./templateParser"; diff --git a/frontend/components/common/SmartExcelUpload/templateGenerator.ts b/frontend/components/common/SmartExcelUpload/templateGenerator.ts new file mode 100644 index 00000000..613d4763 --- /dev/null +++ b/frontend/components/common/SmartExcelUpload/templateGenerator.ts @@ -0,0 +1,528 @@ +/** + * 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[]; + /** 시트별 드롭다운 옵션 (카테고리 값 등) */ + dropdownOptions?: Record; + /** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */ + itemProcessMappings?: ItemProcessMapping[]; + /** 추가 메타 정보 */ + extraMeta?: Record; +} + +export async function generateTemplate( + options: GenerateTemplateOptions +): Promise { + 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(/(? `${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, + 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, + 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, + 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, + 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[], + dropdownOptions: Record, + 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); +} diff --git a/frontend/components/common/SmartExcelUpload/templateParser.ts b/frontend/components/common/SmartExcelUpload/templateParser.ts new file mode 100644 index 00000000..c87674a4 --- /dev/null +++ b/frontend/components/common/SmartExcelUpload/templateParser.ts @@ -0,0 +1,327 @@ +/** + * 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, + }; +} diff --git a/frontend/components/common/SmartExcelUpload/types.ts b/frontend/components/common/SmartExcelUpload/types.ts new file mode 100644 index 00000000..1f69513e --- /dev/null +++ b/frontend/components/common/SmartExcelUpload/types.ts @@ -0,0 +1,151 @@ +/** + * SmartExcelUpload — 설정 기반 엑셀 업로드 공통 모듈 타입 정의 + */ + +/** 드롭다운 소스 설정 */ +export interface DropdownConfig { + /** 드롭다운 소스 유형 */ + source: "category" | "custom" | "reference" | "indirect"; + /** 카테고리 조회 시 테이블명 */ + tableName?: string; + /** 카테고리 조회 시 컬럼명 */ + columnName?: string; + /** 고정 값 목록 (source: "custom") */ + values?: string[]; + /** 참조시트 컬럼에서 가져올 때 (source: "reference") — VLOOKUP 기반 */ + referenceColumn?: string; + /** INDIRECT 연동 시 참조할 컬럼 키 (source: "indirect") — 품목별 공정 등 */ + indirectKeyColumn?: string; + /** INDIRECT 이름 범위 prefix (source: "indirect") — 예: "ACC_" → INDIRECT("ACC_" & 셀값) */ + indirectPrefix?: string; +} + +/** 컬럼 정의 */ +export interface SmartColumn { + /** DB 컬럼명 */ + key: string; + /** 엑셀 헤더 라벨 */ + label: string; + /** 필수 여부 */ + required?: boolean; + /** 데이터 타입 */ + type: "text" | "number" | "date" | "dropdown"; + /** 드롭다운 설정 */ + dropdown?: DropdownConfig; + /** VLOOKUP 자동 입력 (참조시트에서 자동으로 값 가져옴, 사용자 입력 불가) */ + autoFill?: { + /** 참조시트에서 lookup할 키 컬럼 (같은 시트의 어떤 컬럼 값을 기준으로) */ + lookupColumn: string; + /** 참조시트에서 가져올 컬럼명 */ + referenceColumn: string; + }; + /** 조건부 활성화 — 다른 컬럼 값이 일치할 때만 입력 가능 */ + enableWhen?: { + /** 참조할 컬럼 키 */ + column: string; + /** 해당 값일 때만 입력 가능 */ + equals: string; + }; + /** 조건부 비활성화 — 다른 컬럼 값이 일치하면 입력 차단 */ + disableWhen?: { + /** 참조할 컬럼 키 */ + column: string; + /** 해당 값일 때 입력 차단 */ + equals: string; + }; + /** 커스텀 수식 — {col:key} 형태로 같은 행의 다른 컬럼 셀 참조 가능 */ + customFormula?: string; + /** 읽기전용 (자동채움 등) */ + readOnly?: boolean; + /** 컬럼 너비 (기본 18) */ + width?: number; +} + +/** 시트 정의 */ +export interface SheetConfig { + /** 시트명 (예: "수입검사") */ + name: string; + /** DB 저장 시 타입 구분 값 (예: "수입검사") */ + typeKey?: string; + /** 컬럼 정의 */ + columns: SmartColumn[]; +} + +/** 참조시트 정의 — 검사기준정보 등 lookup 데이터 */ +export interface ReferenceSheetConfig { + /** 시트명 */ + name: string; + /** 컬럼 정의 */ + columns: { key: string; label: string }[]; + /** 참조 데이터 (런타임에 주입) */ + data?: Record[]; +} + +/** 조건부 검증 규칙 */ +export interface ConditionalRule { + /** 조건: 어떤 컬럼이 어떤 값일 때 */ + when: { column: string; equals: string }; + /** 필수가 되는 컬럼들 */ + require: string[]; + /** 무시해도 되는 컬럼들 (비어있어도 OK) */ + ignore: string[]; +} + +/** 품목별 공정 매핑 (INDIRECT 동적 드롭다운용) */ +export interface ItemProcessMapping { + /** 품목코드 */ + itemCode: string; + /** 품목명 */ + itemName: string; + /** 해당 품목의 공정 목록 */ + processes: { code: string; name: string }[]; +} + +/** INDIRECT prefix(ACC_) 기반 동적 드롭다운 옵션 정의 */ +export interface IndirectOptionsConfig { + /** 참조시트에서 조건을 판단할 컬럼 키 (예: "judgment_criteria") */ + conditionColumn: string; + /** 조건값→옵션 매핑 */ + optionsByCondition: Record; + /** 참조시트에서 선택옵션을 가져올 컬럼 키 (콤마 구분 문자열, 예: "selection_options") */ + selectionOptionsColumn?: string; +} + +/** 전체 설정 */ +export interface SmartExcelUploadConfig { + /** 템플릿 파일명 (예: "품목검사정보") */ + templateName: string; + /** 시트 정의 목록 */ + sheets: SheetConfig[]; + /** 참조시트 설정 (없으면 생략) */ + referenceSheet?: ReferenceSheetConfig; + /** 조건부 검증 규칙 (없으면 단순 필수값 체크만) */ + conditionalRules?: ConditionalRule[]; + /** INDIRECT ACC_ 동적 드롭다운 옵션 설정 (없으면 ACC_ 시트 미생성) */ + indirectOptions?: IndirectOptionsConfig; + /** 추가 메타 정보 (템플릿에 포함) */ + extraMeta?: Record; +} + +/** 파싱 결과 — 시트별 데이터 */ +export interface ParsedSheetData { + sheetName: string; + typeKey?: string; + rows: Record[]; +} + +/** 검증 에러 */ +export interface ValidationError { + sheet: string; + row: number; + column: string; + message: string; +} + +/** 파싱 + 검증 결과 */ +export interface ParseResult { + success: boolean; + data: ParsedSheetData[]; + errors: ValidationError[]; + warnings: string[]; +}