From 0e8c68a9ff0383bdbb78f0139d6640cde74f20e2 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Mar 2026 19:17:35 +0900 Subject: [PATCH 1/2] feat: Add multi-table Excel upload functionality - Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains. - The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table. - The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process. - Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler. This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently. --- backend-node/src/routes/dataRoutes.ts | 112 ++ .../src/services/multiTableExcelService.ts | 1074 +++++++++++++++++ .../src/services/tableManagementService.ts | 8 +- docs/plan-multi-table-excel-upload.md | 194 +++ .../common/MultiTableExcelUploadModal.tsx | 786 ++++++++++++ .../config-panels/ButtonConfigPanel.tsx | 8 +- .../config-panels/V2RepeaterConfigPanel.tsx | 16 +- frontend/lib/api/multiTableExcel.ts | 98 ++ .../SplitPanelLayoutComponent.tsx | 59 +- frontend/lib/utils/buttonActions.ts | 72 ++ frontend/lib/utils/excelExport.ts | 26 +- 11 files changed, 2384 insertions(+), 69 deletions(-) create mode 100644 backend-node/src/services/multiTableExcelService.ts create mode 100644 docs/plan-multi-table-excel-upload.md create mode 100644 frontend/components/common/MultiTableExcelUploadModal.tsx create mode 100644 frontend/lib/api/multiTableExcel.ts diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 0bae3617..de83e720 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,6 +1,7 @@ import express from "express"; import { dataService } from "../services/dataService"; import { masterDetailExcelService } from "../services/masterDetailExcelService"; +import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { auditLogService } from "../services/auditLogService"; @@ -260,6 +261,117 @@ router.post( } ); +// ================================ +// 다중 테이블 엑셀 업로드 API +// ================================ + +/** + * 다중 테이블 자동 감지 + * GET /api/data/multi-table/auto-detect?rootTable=customer_mng + * + * 루트 테이블명만 넘기면 FK 관계를 자동 탐색하여 + * 완성된 TableChainConfig를 반환한다. + */ +router.get( + "/multi-table/auto-detect", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const rootTable = req.query.rootTable as string; + const screenId = req.query.screenId ? Number(req.query.screenId) : undefined; + const companyCode = req.user?.companyCode || "*"; + + if (!rootTable) { + return res.status(400).json({ + success: false, + message: "rootTable 파라미터가 필요합니다.", + }); + } + + const config = await multiTableExcelService.autoDetectTableChain( + rootTable, + companyCode, + screenId + ); + + return res.json({ success: true, data: config }); + } catch (error: any) { + console.error("다중 테이블 자동 감지 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "자동 감지 중 오류가 발생했습니다.", + }); + } + } +); + +/** + * 다중 테이블 엑셀 업로드 + * POST /api/data/multi-table/upload + * + * Body: { config: TableChainConfig, modeId: string, rows: Record[] } + */ +router.post( + "/multi-table/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { config, modeId, rows } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!config || !modeId || !rows || !Array.isArray(rows)) { + return res.status(400).json({ + success: false, + message: "config, modeId, rows 배열이 필요합니다.", + }); + } + + if (rows.length === 0) { + return res.status(400).json({ + success: false, + message: "업로드할 데이터가 없습니다.", + }); + } + + console.log(`다중 테이블 엑셀 업로드:`, { + configId: config.id, + modeId, + rowCount: rows.length, + companyCode, + userId, + }); + + const result = await multiTableExcelService.uploadMultiTable( + config as TableChainConfig, + modeId, + rows, + companyCode, + userId + ); + + const summaryParts = result.results.map( + (r) => `${r.tableName}: 신규 ${r.inserted}건, 수정 ${r.updated}건` + ); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? summaryParts.join(" / ") + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("다중 테이블 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "다중 테이블 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + // ================================ // 기존 데이터 API // ================================ diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts new file mode 100644 index 00000000..d18f479b --- /dev/null +++ b/backend-node/src/services/multiTableExcelService.ts @@ -0,0 +1,1074 @@ +/** + * 다중 테이블 엑셀 업로드 범용 서비스 + * + * 하나의 플랫 엑셀 데이터를 계층적 다중 테이블(2~N개)에 + * 트랜잭션으로 일괄 UPSERT하는 범용 엔진. + * + * 적용 사례: + * - 거래처: customer_mng → customer_item_mapping → customer_item_prices + * - 공급업체: supplier_mng → supplier_item_mapping → supplier_item_prices + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** 테이블 계층 레벨 설정 */ +export interface TableLevel { + tableName: string; + label: string; + parentFkColumn?: string; + parentRefColumn?: string; + upsertMode: "upsert" | "insert"; + upsertKeyColumns?: string[]; + columns: ColumnDef[]; +} + +/** 컬럼 정의 */ +export interface ColumnDef { + dbColumn: string; + excelHeader: string; + required: boolean; + defaultValue?: any; +} + +/** 업로드 모드 정의 */ +export interface UploadMode { + id: string; + label: string; + description: string; + activeLevels: number[]; +} + +/** 테이블 체인 설정 (범용) */ +export interface TableChainConfig { + id: string; + name: string; + description: string; + levels: TableLevel[]; + uploadModes: UploadMode[]; +} + +/** 레벨별 업로드 결과 */ +export interface LevelResult { + tableName: string; + inserted: number; + updated: number; +} + +/** 전체 업로드 결과 */ +export interface MultiTableUploadResult { + success: boolean; + results: LevelResult[]; + totalRows: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MultiTableExcelService { + /** + * 다중 테이블 엑셀 업로드 실행 + * + * @param config 테이블 체인 설정 + * @param modeId 업로드 모드 ID + * @param rows 엑셀에서 파싱된 플랫 JSON 배열 (excelHeader 기준) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + */ + async uploadMultiTable( + config: TableChainConfig, + modeId: string, + rows: Record[], + companyCode: string, + userId: string + ): Promise { + const result: MultiTableUploadResult = { + success: false, + results: [], + totalRows: rows.length, + errors: [], + }; + + const mode = config.uploadModes.find((m) => m.id === modeId); + if (!mode) { + result.errors.push(`업로드 모드를 찾을 수 없습니다: ${modeId}`); + return result; + } + + const activeLevels = mode.activeLevels + .map((i) => config.levels[i]) + .filter(Boolean); + + if (activeLevels.length === 0) { + result.errors.push("활성화된 테이블 레벨이 없습니다."); + return result; + } + + // 레벨별 결과 초기화 + for (const level of activeLevels) { + result.results.push({ + tableName: level.tableName, + inserted: 0, + updated: 0, + }); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 각 레벨의 실제 DB 컬럼 존재 여부 캐시 + const existingColsCache = new Map>(); + for (const level of activeLevels) { + const colsResult = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [level.tableName] + ); + existingColsCache.set( + level.tableName, + new Set(colsResult.rows.map((r: any) => r.column_name)) + ); + } + + // 엑셀 헤더 → DB 컬럼 매핑 테이블 구축 (활성 레벨만) + const headerToColumn = new Map(); + for (let i = 0; i < activeLevels.length; i++) { + for (const col of activeLevels[i].columns) { + headerToColumn.set(col.excelHeader, { + levelIndex: i, + dbColumn: col.dbColumn, + }); + } + } + + // 행 단위로 처리 (트랜잭션 내) + // 부모 ID 캐시: 각 레벨에서 upsertKey → 반환된 PK 매핑 + const pkCaches: Map[] = activeLevels.map( + () => new Map() + ); + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + const row = rows[rowIdx]; + + try { + let parentId: string | number | null = null; + let parentLevelData: Record = {}; + + for (let lvlIdx = 0; lvlIdx < activeLevels.length; lvlIdx++) { + const level = activeLevels[lvlIdx]; + const levelResult = result.results[lvlIdx]; + const existingCols = existingColsCache.get(level.tableName)!; + + const levelData: Record = {}; + for (const colDef of level.columns) { + const excelValue = row[colDef.excelHeader]; + if (excelValue !== undefined && excelValue !== null && excelValue !== "") { + levelData[colDef.dbColumn] = excelValue; + } else if (colDef.defaultValue !== undefined) { + levelData[colDef.dbColumn] = colDef.defaultValue; + } + } + + const hasAnyData = Object.keys(levelData).length > 0; + if (!hasAnyData && lvlIdx > 0) { + break; + } + + const missingRequired = level.columns + .filter((c) => c.required && !levelData[c.dbColumn]) + .map((c) => c.excelHeader); + + if (missingRequired.length > 0) { + result.errors.push( + `[행 ${rowIdx + 1}] ${level.label} 필수 컬럼 누락: ${missingRequired.join(", ")}` + ); + break; + } + + // 부모 FK 주입: parentRefColumn이 'id'가 아닌 경우 부모 데이터에서 해당 컬럼 값 사용 + if (lvlIdx > 0 && level.parentFkColumn && parentId !== null) { + if ( + level.parentRefColumn && + level.parentRefColumn !== "id" && + parentLevelData[level.parentRefColumn] !== undefined && + parentLevelData[level.parentRefColumn] !== null + ) { + levelData[level.parentFkColumn] = String( + parentLevelData[level.parentRefColumn] + ); + } else { + levelData[level.parentFkColumn] = String(parentId); + } + } + + if (existingCols.has("company_code")) { + levelData.company_code = companyCode; + } + if (existingCols.has("writer")) { + levelData.writer = userId; + } + + const upsertKey = level.upsertKeyColumns + ? level.upsertKeyColumns.map((k) => String(levelData[k] ?? "")).join("|||") + : null; + + let returnedId: string | number; + + if (level.upsertMode === "upsert" && upsertKey) { + const cachedId = pkCaches[lvlIdx].get(upsertKey); + if (cachedId !== undefined) { + returnedId = cachedId; + } else { + returnedId = await this.upsertRow( + client, + level, + levelData, + existingCols, + companyCode, + levelResult + ); + pkCaches[lvlIdx].set(upsertKey, returnedId); + } + } else { + returnedId = await this.insertRow( + client, + level.tableName, + levelData, + existingCols + ); + levelResult.inserted++; + } + + parentId = returnedId; + parentLevelData = { ...levelData }; + } + } catch (error: any) { + result.errors.push(`[행 ${rowIdx + 1}] 처리 실패: ${error.message}`); + logger.error(`[행 ${rowIdx + 1}] 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = + result.errors.length === 0 || + result.results.some((r) => r.inserted + r.updated > 0); + + logger.info("다중 테이블 엑셀 업로드 완료:", { + results: result.results, + errors: result.errors.length, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error("다중 테이블 엑셀 업로드 트랜잭션 실패:", error); + } finally { + client.release(); + } + + return result; + } + + /** + * UPSERT 행 처리 (존재하면 UPDATE, 없으면 INSERT) + * @returns 해당 행의 PK (id) + */ + private async upsertRow( + client: any, + level: TableLevel, + data: Record, + existingCols: Set, + companyCode: string, + levelResult: LevelResult + ): Promise { + const { tableName, upsertKeyColumns } = level; + + if (!upsertKeyColumns || upsertKeyColumns.length === 0) { + const id = await this.insertRow(client, tableName, data, existingCols); + levelResult.inserted++; + return id; + } + + // UPSERT 키로 기존 행 조회 + const whereClause = upsertKeyColumns + .map((col, i) => `"${col}" = $${i + 1}`) + .join(" AND "); + + const companyIdx = upsertKeyColumns.length + 1; + const companyWhere = existingCols.has("company_code") + ? ` AND company_code = $${companyIdx}` + : ""; + + const params = upsertKeyColumns.map((col) => data[col]); + if (existingCols.has("company_code")) { + params.push(companyCode); + } + + const existing = await client.query( + `SELECT id FROM "${tableName}" WHERE ${whereClause}${companyWhere} LIMIT 1`, + params + ); + + if (existing.rows.length > 0) { + // UPDATE + const existingId = existing.rows[0].id; + const skipCols = new Set([ + "id", + "company_code", + "created_date", + ...upsertKeyColumns, + ]); + const updateKeys = Object.keys(data).filter( + (k) => !skipCols.has(k) && existingCols.has(k) + ); + + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map((k) => data[k]); + const updatedDateClause = existingCols.has("updated_date") + ? `, updated_date = NOW()` + : ""; + + await client.query( + `UPDATE "${tableName}" SET ${setClauses.join(", ")}${updatedDateClause} + WHERE id = $${setValues.length + 1}`, + [...setValues, existingId] + ); + } + levelResult.updated++; + return existingId; + } else { + // INSERT + const id = await this.insertRow(client, tableName, data, existingCols); + levelResult.inserted++; + return id; + } + } + + /** + * 단순 INSERT 후 PK(id) 반환 + */ + private async insertRow( + client: any, + tableName: string, + data: Record, + existingCols: Set + ): Promise { + // DB에 실제 존재하는 컬럼만 필터 + const cols = Object.keys(data).filter( + (k) => existingCols.has(k) && k !== "id" && data[k] !== undefined + ); + + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map((k) => data[k]); + + const result = await client.query( + `INSERT INTO "${tableName}" (${colList.map((c) => `"${c}"`).join(", ")}) + VALUES (${valList.join(", ")}) + RETURNING id`, + values + ); + + return result.rows[0].id; + } + + /** + * 모드에 맞는 템플릿 데이터 생성 (엑셀 다운로드용) + */ + generateTemplateData( + config: TableChainConfig, + modeId: string + ): { headers: string[]; requiredHeaders: string[]; sampleRow: Record } { + const mode = config.uploadModes.find((m) => m.id === modeId); + if (!mode) { + throw new Error(`업로드 모드를 찾을 수 없습니다: ${modeId}`); + } + + const headers: string[] = []; + const requiredHeaders: string[] = []; + const sampleRow: Record = {}; + + for (const levelIdx of mode.activeLevels) { + const level = config.levels[levelIdx]; + if (!level) continue; + + for (const col of level.columns) { + headers.push(col.excelHeader); + if (col.required) { + requiredHeaders.push(col.excelHeader); + } + sampleRow[col.excelHeader] = col.required ? `(필수)` : ""; + } + } + + return { headers, requiredHeaders, sampleRow }; + } + + // ================================ + // 자동 감지 + // ================================ + + // 엑셀 업로드에서 제외할 시스템 컬럼 + private static SYSTEM_COLUMNS = new Set([ + "id", + "company_code", + "writer", + "created_date", + "updated_date", + "created_at", + "updated_at", + ]); + + /** + * 루트 테이블에서 자식/손자 테이블을 자동 탐색하여 + * TableChainConfig를 실시간으로 생성한다. + * + * @param screenId 화면 ID (선택). 제공 시 관련 화면의 테이블을 참고하여 + * 다수 자식 중 올바른 체인을 선택한다. + */ + async autoDetectTableChain( + rootTableName: string, + companyCode: string, + screenId?: number + ): Promise { + const pool = getPool(); + + // 1) 루트 테이블이 존재하는지 확인 + const tableCheck = await pool.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1`, + [rootTableName] + ); + if (tableCheck.rows.length === 0) { + throw new Error(`테이블이 존재하지 않습니다: ${rootTableName}`); + } + + // 2) 화면 컨텍스트에서 관련 테이블 세트 + 보이는 컬럼 추출 + let contextTables = new Set(); + let visibleColumnsMap = new Map>(); + + if (screenId) { + const ctx = await this.getScreenContext(pool, screenId, companyCode); + contextTables = ctx.contextTables; + visibleColumnsMap = ctx.visibleColumns; + } + + logger.info("자동 감지 시작", { + rootTableName, + screenId, + contextTables: [...contextTables], + visibleColumnsPerTable: Object.fromEntries( + [...visibleColumnsMap].map(([k, v]) => [k, [...v]]) + ), + }); + + // 3) 루트부터 재귀적으로 자식 탐색 (최대 3레벨) + const levels: TableLevel[] = []; + await this.buildLevelChain(pool, rootTableName, companyCode, levels, 0, 3, contextTables); + + // 3.5) 화면 레이아웃에서 보이는 컬럼으로 필터링 + if (visibleColumnsMap.size > 0) { + for (const level of levels) { + const visibleCols = visibleColumnsMap.get(level.tableName); + if (visibleCols && visibleCols.size > 0) { + const before = level.columns.length; + level.columns = level.columns.filter((col) => visibleCols.has(col.dbColumn)); + logger.info(`컬럼 필터링: ${level.tableName}`, { + before, + after: level.columns.length, + visibleCols: [...visibleCols], + }); + } + } + } + + // 4) 업로드 모드 자동 생성 + const uploadModes: UploadMode[] = []; + for (let depth = 0; depth < levels.length; depth++) { + const activeLevels = Array.from({ length: depth + 1 }, (_, i) => i); + const labels = activeLevels.map((i) => levels[i].label); + uploadModes.push({ + id: `auto_mode_${depth}`, + label: labels.join(" + "), + description: `${labels.join(", ")} 일괄 등록`, + activeLevels, + }); + } + + // 5) 라벨 생성 + const rootLabel = await this.getTableLabel(pool, rootTableName, companyCode); + + return { + id: rootTableName, + name: rootLabel, + description: `${rootLabel} 다중 테이블 엑셀 업로드 (자동 감지)`, + levels, + uploadModes, + }; + } + + /** + * 재귀적으로 테이블 계층 빌드 + * contextTables: 화면의 관련 테이블 세트 (자식 선택 시 우선순위에 사용) + */ + private async buildLevelChain( + pool: any, + tableName: string, + companyCode: string, + levels: TableLevel[], + depth: number, + maxDepth: number, + contextTables: Set + ): Promise { + if (depth >= maxDepth) return; + + const columns = await this.getTableColumns(pool, tableName, companyCode); + + const { upsertMode, upsertKeyColumns } = await this.detectUpsertKeys( + pool, + tableName + ); + + const label = await this.getTableLabel(pool, tableName, companyCode); + + let parentFkColumn: string | undefined; + let parentRefColumn: string | undefined; + if (depth > 0 && levels.length > 0) { + const parentTable = levels[depth - 1].tableName; + const fkInfo = await this.findFkColumn(pool, tableName, parentTable, companyCode); + if (fkInfo) { + parentFkColumn = fkInfo.fkColumn; + parentRefColumn = fkInfo.refColumn; + } + } + + const level: TableLevel = { + tableName, + label, + upsertMode, + upsertKeyColumns: upsertMode === "upsert" ? upsertKeyColumns : undefined, + columns, + ...(parentFkColumn ? { parentFkColumn, parentRefColumn } : {}), + }; + + levels.push(level); + + const childTables = await this.findChildTables(pool, tableName, companyCode); + if (childTables.length > 0) { + const bestChild = this.pickBestChild(tableName, childTables, contextTables); + await this.buildLevelChain(pool, bestChild, companyCode, levels, depth + 1, maxDepth, contextTables); + } + } + + /** + * 여러 자식 테이블 중 가장 관련성 높은 하나를 선택 + * + * 우선순위: + * 1. 화면 컨텍스트 테이블에 포함된 자식 (같은 화면 관련 모달이 사용하는 테이블) + * 2. 부모 테이블명의 접두사를 공유하는 자식 + * 3. 첫 번째 자식 + */ + private pickBestChild( + parentTable: string, + children: string[], + contextTables: Set + ): string { + if (children.length === 1) return children[0]; + + // 1순위: 화면 컨텍스트 테이블에 있는 자식 + if (contextTables.size > 0) { + const contextMatch = children.find((c) => contextTables.has(c)); + if (contextMatch) { + logger.info(`pickBestChild: 화면 컨텍스트 매칭 - ${contextMatch}`, { + parentTable, + candidates: children, + }); + return contextMatch; + } + } + + // 2순위: 부모 테이블의 접두사 매칭 (예: customer_mng → customer_item_mapping) + const parentPrefix = parentTable.split("_")[0]; + const prefixMatch = children.find((c) => c.startsWith(parentPrefix + "_")); + if (prefixMatch) return prefixMatch; + + return children[0]; + } + + /** + * 화면 ID로부터 관련 화면 정보를 통합 추출: + * 1) contextTables: 자식 테이블 선택에 사용할 관련 테이블 세트 + * 2) visibleColumns: 각 테이블별 화면에서 실제 사용 중인 컬럼 세트 + * + * 추출 전략: + * A) 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀 추적 + * B) 화면명 키워드로 관련 화면 검색 + * C) 같은 테이블의 등록/수정 폼 검색 + */ + private async getScreenContext( + pool: any, + screenId: number, + companyCode: string + ): Promise<{ + contextTables: Set; + visibleColumns: Map>; + }> { + const emptyResult = { + contextTables: new Set(), + visibleColumns: new Map>(), + }; + + const screenResult = await pool.query( + `SELECT screen_name, table_name FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + + if (screenResult.rows.length === 0) return emptyResult; + + const screenName: string = screenResult.rows[0].screen_name; + const rootTable: string = screenResult.rows[0].table_name; + const companyPrefix = screenName.split(/\s+/)[0] || ""; + + const contextTables = new Set(); + const collectedScreenIds = new Set(); + + // ─── A) 화면명 키워드로 관련 화면 검색 (자식 테이블 선택용) ─── + const suffixWords = new Set(["화면", "모달", "폼", "페이지"]); + const nameTokens = screenName.split(/\s+/); + const middleTokens = nameTokens.filter((t, i) => { + if (i === 0) return false; + if (suffixWords.has(t)) return false; + return t.length >= 2; + }); + + for (const token of middleTokens) { + const relatedResult = await pool.query( + `SELECT screen_id, table_name + FROM screen_definitions + WHERE screen_name LIKE $1 + AND table_name IS NOT NULL`, + [`%${token}%`] + ); + for (const row of relatedResult.rows) { + if (row.table_name && row.table_name !== rootTable) { + contextTables.add(row.table_name); + } + collectedScreenIds.add(row.screen_id); + } + } + + // ─── B) 모달 참조 체인 재귀 추적 (컬럼 추출용) ─── + await this.collectModalChain(pool, screenId, collectedScreenIds, 0, 4); + + // ─── C) 수집된 테이블의 등록/수정/입력 폼도 포함 (컬럼 추출용) ─── + const allTablesInChain = new Set([rootTable]); + if (collectedScreenIds.size > 0) { + const idArr = [...collectedScreenIds]; + const ph = idArr.map((_, i) => `$${i + 1}`).join(","); + const tableResult = await pool.query( + `SELECT DISTINCT table_name FROM screen_definitions WHERE screen_id IN (${ph}) AND table_name IS NOT NULL`, + idArr + ); + for (const row of tableResult.rows) { + allTablesInChain.add(row.table_name); + } + } + + for (const tbl of allTablesInChain) { + const formResult = await pool.query( + `SELECT sd.screen_id + FROM screen_definitions sd + JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id + WHERE sd.table_name = $1 + AND sd.screen_name LIKE $2 + AND (sd.screen_name LIKE '%등록%' OR sd.screen_name LIKE '%수정%' OR sd.screen_name LIKE '%입력%')`, + [tbl, `${companyPrefix}%`] + ); + for (const row of formResult.rows) { + collectedScreenIds.add(row.screen_id); + } + } + + // ─── 수집된 화면들의 layout_data에서 테이블별 컬럼 추출 ─── + const visibleColumns = new Map>(); + + if (collectedScreenIds.size > 0) { + const screenIdArray = [...collectedScreenIds]; + const placeholders = screenIdArray.map((_, i) => `$${i + 1}`).join(","); + + const layoutResult = await pool.query( + `SELECT sd.screen_id, sd.table_name, lv.layout_data + FROM screen_definitions sd + JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id + WHERE sd.screen_id IN (${placeholders}) + AND lv.layout_data IS NOT NULL`, + screenIdArray + ); + + for (const row of layoutResult.rows) { + const tableName = row.table_name; + if (!tableName) continue; + + if (tableName !== rootTable) { + contextTables.add(tableName); + } + + if (!visibleColumns.has(tableName)) { + visibleColumns.set(tableName, new Set()); + } + const colSet = visibleColumns.get(tableName)!; + this.extractColumnsFromLayout(row.layout_data, colSet); + } + } + + logger.info("화면 컨텍스트 추출", { + screenId, + screenName, + collectedScreenCount: collectedScreenIds.size, + contextTables: [...contextTables], + visibleColumnsPerTable: Object.fromEntries( + [...visibleColumns].map(([k, v]) => [k, [...v]]) + ), + }); + + return { contextTables, visibleColumns }; + } + + /** + * 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀적으로 추적 + * 최대 depth까지 모달 → 서브모달 → ... 체인을 따라감 + */ + private async collectModalChain( + pool: any, + screenId: number, + collected: Set, + depth: number, + maxDepth: number + ): Promise { + if (depth >= maxDepth || collected.has(screenId)) return; + collected.add(screenId); + + const layoutResult = await pool.query( + `SELECT lv.layout_data + FROM screen_layouts_v3 lv + WHERE lv.screen_id = $1 AND lv.layout_data IS NOT NULL`, + [screenId] + ); + + if (layoutResult.rows.length === 0) return; + + const layoutData = layoutResult.rows[0].layout_data; + const referencedIds = new Set(); + this.extractModalReferences(layoutData, referencedIds); + + for (const refId of referencedIds) { + await this.collectModalChain(pool, refId, collected, depth + 1, maxDepth); + } + } + + /** + * layout_data에서 targetScreenId, modalScreenId 값을 추출 + */ + private extractModalReferences(obj: any, refs: Set): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + if (item && typeof item === "object") { + this.extractModalReferences(item, refs); + } + } + return; + } + + for (const key of ["targetScreenId", "modalScreenId"]) { + const val = obj[key]; + if (val !== undefined && val !== null) { + const num = Number(val); + if (!isNaN(num) && num > 0) { + refs.add(num); + } + } + } + + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === "object") { + this.extractModalReferences(val, refs); + } + } + } + + /** + * layout_data JSON에서 사용 중인 컬럼명을 재귀적으로 추출 + * - columnName 속성 (v2-input, v2-select 등) + * - columns[].name 속성 (테이블 리스트, 분할 패널) + * - dot notation (supplier_mng.supplier_name) 은 JOIN이므로 제외 + */ + private extractColumnsFromLayout( + layoutData: any, + colSet: Set + ): void { + if (!layoutData || typeof layoutData !== "object") return; + + if (Array.isArray(layoutData)) { + for (const item of layoutData) { + if (item && typeof item === "object") { + this.extractColumnsFromLayout(item, colSet); + } + } + return; + } + + // columnName 속성 (폼 필드) + const cn = layoutData.columnName; + if (cn && typeof cn === "string" && cn.trim() && !cn.includes(".")) { + colSet.add(cn); + } + + // columns 배열 (리스트/테이블) + const cols = layoutData.columns; + if (Array.isArray(cols)) { + for (const col of cols) { + if (col && typeof col === "object") { + const name = col.name; + if (name && typeof name === "string" && !name.includes(".")) { + colSet.add(name); + } + } + } + } + + // 재귀 탐색 + for (const key of Object.keys(layoutData)) { + const val = layoutData[key]; + if (val && typeof val === "object") { + this.extractColumnsFromLayout(val, colSet); + } + } + } + + /** + * 특정 테이블을 참조하는 자식 테이블 목록 찾기 + * 1차: table_type_columns의 reference_table + * 2차: pg_constraint FK + */ + private async findChildTables( + pool: any, + parentTable: string, + companyCode: string + ): Promise { + // 1) table_type_columns에서 이 테이블을 reference_table로 가진 다른 테이블 검색 + const ttcResult = await pool.query( + `SELECT DISTINCT table_name + FROM table_type_columns + WHERE reference_table = $1 + AND table_name != $1 + AND company_code IN ($2, '*') + ORDER BY table_name`, + [parentTable, companyCode] + ); + + if (ttcResult.rows.length > 0) { + return ttcResult.rows.map((r: any) => r.table_name); + } + + // 2) fallback: pg_constraint FK + const fkResult = await pool.query( + `SELECT DISTINCT c2.relname AS child_table + FROM pg_constraint con + JOIN pg_class c1 ON con.confrelid = c1.oid + JOIN pg_class c2 ON con.conrelid = c2.oid + JOIN pg_namespace ns ON c2.relnamespace = ns.oid + WHERE ns.nspname = 'public' + AND c1.relname = $1 + AND con.contype = 'f' + AND c2.relname != $1 + ORDER BY c2.relname`, + [parentTable] + ); + + return fkResult.rows.map((r: any) => r.child_table); + } + + /** + * 자식 테이블에서 부모 테이블을 참조하는 FK 컬럼 찾기 + */ + private async findFkColumn( + pool: any, + childTable: string, + parentTable: string, + companyCode: string + ): Promise<{ fkColumn: string; refColumn: string } | null> { + // 1) table_type_columns + const ttcResult = await pool.query( + `SELECT column_name, reference_column + FROM table_type_columns + WHERE table_name = $1 + AND reference_table = $2 + AND company_code IN ($3, '*') + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + [childTable, parentTable, companyCode] + ); + + if (ttcResult.rows.length > 0) { + return { + fkColumn: ttcResult.rows[0].column_name, + refColumn: ttcResult.rows[0].reference_column || "id", + }; + } + + // 2) pg_constraint FK + const fkResult = await pool.query( + `SELECT a_child.attname AS fk_column, + a_parent.attname AS ref_column + FROM pg_constraint con + JOIN pg_class c_child ON con.conrelid = c_child.oid + JOIN pg_class c_parent ON con.confrelid = c_parent.oid + JOIN pg_namespace ns ON c_child.relnamespace = ns.oid + JOIN pg_attribute a_child ON a_child.attrelid = c_child.oid AND a_child.attnum = ANY(con.conkey) + JOIN pg_attribute a_parent ON a_parent.attrelid = c_parent.oid AND a_parent.attnum = ANY(con.confkey) + WHERE ns.nspname = 'public' + AND c_child.relname = $1 + AND c_parent.relname = $2 + AND con.contype = 'f' + LIMIT 1`, + [childTable, parentTable] + ); + + if (fkResult.rows.length > 0) { + return { + fkColumn: fkResult.rows[0].fk_column, + refColumn: fkResult.rows[0].ref_column, + }; + } + + return null; + } + + /** + * 테이블의 컬럼 목록을 가져와 ColumnDef 배열로 변환 + * 시스템 컬럼과 FK 컬럼은 제외 + */ + private async getTableColumns( + pool: any, + tableName: string, + companyCode: string + ): Promise { + const result = await pool.query( + `SELECT + c.column_name, + c.is_nullable, + c.column_default, + COALESCE(ttc.column_label, cl.column_label) AS column_label, + COALESCE(ttc.reference_table, cl.reference_table) AS reference_table + FROM information_schema.columns c + LEFT JOIN table_type_columns cl + ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' + LEFT JOIN table_type_columns ttc + ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $2 + WHERE c.table_schema = 'public' AND c.table_name = $1 + ORDER BY c.ordinal_position`, + [tableName, companyCode] + ); + + const columns: ColumnDef[] = []; + for (const row of result.rows) { + const colName: string = row.column_name; + + // 시스템 컬럼 제외 + if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; + + // FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) + // 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로, + // _id로 끝나면서 reference_table이 있는 경우만 제외 + if (row.reference_table && colName.endsWith("_id")) continue; + + const hasDefault = row.column_default !== null; + const isNullable = row.is_nullable === "YES"; + const isRequired = !isNullable && !hasDefault; + + columns.push({ + dbColumn: colName, + excelHeader: row.column_label || colName, + required: isRequired, + }); + } + + return columns; + } + + /** + * UPSERT 키 감지: UNIQUE 제약조건 → 없으면 insert 모드 + * company_code가 포함된 UNIQUE 제약조건에서 company_code를 제외한 컬럼 사용 + */ + private async detectUpsertKeys( + pool: any, + tableName: string + ): Promise<{ upsertMode: "upsert" | "insert"; upsertKeyColumns: string[] }> { + const result = await pool.query( + `SELECT con.conname, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint con + JOIN pg_class c ON con.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' + AND c.relname = $1 + AND con.contype = 'u' + GROUP BY con.conname + ORDER BY con.conname + LIMIT 1`, + [tableName] + ); + + if (result.rows.length > 0) { + let rawCols = result.rows[0].columns; + // pg 드라이버가 array_agg를 문자열로 반환할 수 있음 + if (typeof rawCols === "string") { + rawCols = rawCols.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()); + } + const cols: string[] = (rawCols as string[]).filter( + (c: string) => c !== "company_code" + ); + if (cols.length > 0) { + return { upsertMode: "upsert", upsertKeyColumns: cols }; + } + } + + return { upsertMode: "insert", upsertKeyColumns: [] }; + } + + /** + * 테이블의 한글 라벨 가져오기 + */ + private async getTableLabel( + pool: any, + tableName: string, + companyCode: string + ): Promise { + const result = await pool.query( + `SELECT COALESCE( + (SELECT table_label FROM table_labels WHERE table_name = $1 LIMIT 1), + $1 + ) AS label`, + [tableName] + ); + return result.rows[0]?.label || tableName; + } +} + +export const multiTableExcelService = new MultiTableExcelService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9dea4037..ed7ad460 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3783,15 +3783,15 @@ export class TableManagementService { ); } } else if (operator === "equals") { - // 🔧 equals 연산자: 정확히 일치 + // 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용) whereConditions.push( - `${alias}.${joinConfig.displayColumn}::text = '${safeValue}'` + `main.${joinConfig.sourceColumn}::text = '${safeValue}'` ); entitySearchColumns.push( - `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` + `${key} (main.${joinConfig.sourceColumn})` ); logger.info( - `🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})` + `🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'` ); } else { // 기본: 부분 일치 (ILIKE) diff --git a/docs/plan-multi-table-excel-upload.md b/docs/plan-multi-table-excel-upload.md new file mode 100644 index 00000000..92b72f16 --- /dev/null +++ b/docs/plan-multi-table-excel-upload.md @@ -0,0 +1,194 @@ +# 다중 테이블 엑셀 업로드 범용 시스템 + +## 개요 +하나의 플랫 엑셀 파일로 계층적 다중 테이블(2~N개)에 데이터를 일괄 등록하는 범용 시스템. +거래처 관리(customer_mng → customer_item_mapping → customer_item_prices)를 첫 번째 적용 대상으로 하되, +공급업체, BOM 등 다른 화면에서도 재사용 가능하도록 설계한다. + +## 핵심 기능 +1. 모드 선택: 어느 레벨까지 등록할지 사용자가 선택 +2. 템플릿 다운로드: 모드에 맞는 엑셀 양식 자동 생성 +3. 파일 업로드: 플랫 엑셀 → 계층 그룹핑 → 트랜잭션 UPSERT +4. 컬럼 매핑: 엑셀 헤더 ↔ DB 컬럼 자동/수동 매핑 + +## DB 테이블 관계 (거래처 관리) + +``` +customer_mng (Level 1 - 루트) + PK: id (SERIAL) + UNIQUE: customer_code + └─ customer_item_mapping (Level 2) + PK: id (UUID) + FK: customer_id → customer_mng.id + UPSERT키: customer_id + customer_item_code + └─ customer_item_prices (Level 3) + PK: id (UUID) + FK: mapping_id → customer_item_mapping.id + 항상 INSERT (기간별 단가 이력) +``` + +## 범용 설정 구조 (TableChainConfig) + +```typescript +interface TableLevel { + tableName: string; + label: string; + // 부모와의 관계 + parentFkColumn?: string; // 이 테이블에서 부모를 참조하는 FK 컬럼 + parentRefColumn?: string; // 부모 테이블에서 참조되는 컬럼 (PK 또는 UNIQUE) + // UPSERT 설정 + upsertMode: 'upsert' | 'insert'; // upsert: 기존 데이터 있으면 UPDATE, insert: 항상 신규 + upsertKeyColumns?: string[]; // UPSERT 매칭 키 (예: ['customer_code']) + // 엑셀 매핑 컬럼 + columns: Array<{ + dbColumn: string; + excelHeader: string; + required: boolean; + defaultValue?: any; + }>; +} + +interface TableChainConfig { + id: string; + name: string; + description: string; + levels: TableLevel[]; // 0 = 루트, 1 = 자식, 2 = 손자... + uploadModes: Array<{ + id: string; + label: string; + description: string; + activeLevels: number[]; // 이 모드에서 활성화되는 레벨 인덱스 + }>; +} +``` + +## 거래처 관리 설정 예시 + +```typescript +const customerChainConfig: TableChainConfig = { + id: 'customer_management', + name: '거래처 관리', + description: '거래처, 품목매핑, 단가 일괄 등록', + levels: [ + { + tableName: 'customer_mng', + label: '거래처', + upsertMode: 'upsert', + upsertKeyColumns: ['customer_code'], + columns: [ + { dbColumn: 'customer_code', excelHeader: '거래처코드', required: true }, + { dbColumn: 'customer_name', excelHeader: '거래처명', required: true }, + { dbColumn: 'division', excelHeader: '구분', required: false }, + { dbColumn: 'contact_person', excelHeader: '담당자', required: false }, + { dbColumn: 'contact_phone', excelHeader: '연락처', required: false }, + { dbColumn: 'email', excelHeader: '이메일', required: false }, + { dbColumn: 'business_number', excelHeader: '사업자번호', required: false }, + { dbColumn: 'address', excelHeader: '주소', required: false }, + ], + }, + { + tableName: 'customer_item_mapping', + label: '품목매핑', + parentFkColumn: 'customer_id', + parentRefColumn: 'id', + upsertMode: 'upsert', + upsertKeyColumns: ['customer_id', 'customer_item_code'], + columns: [ + { dbColumn: 'customer_item_code', excelHeader: '거래처품번', required: true }, + { dbColumn: 'customer_item_name', excelHeader: '거래처품명', required: true }, + { dbColumn: 'item_id', excelHeader: '품목ID', required: false }, + ], + }, + { + tableName: 'customer_item_prices', + label: '단가', + parentFkColumn: 'mapping_id', + parentRefColumn: 'id', + upsertMode: 'insert', + columns: [ + { dbColumn: 'base_price', excelHeader: '기준단가', required: true }, + { dbColumn: 'discount_type', excelHeader: '할인유형', required: false }, + { dbColumn: 'discount_value', excelHeader: '할인값', required: false }, + { dbColumn: 'start_date', excelHeader: '적용시작일', required: false }, + { dbColumn: 'end_date', excelHeader: '적용종료일', required: false }, + { dbColumn: 'currency_code', excelHeader: '통화', required: false }, + ], + }, + ], + uploadModes: [ + { id: 'customer_only', label: '거래처만 등록', description: '거래처 기본정보만', activeLevels: [0] }, + { id: 'customer_item', label: '거래처 + 품목정보', description: '거래처와 품목매핑', activeLevels: [0, 1] }, + { id: 'customer_item_price', label: '거래처 + 품목 + 단가', description: '전체 등록', activeLevels: [0, 1, 2] }, + ], +}; +``` + +## 처리 로직 (백엔드) + +### 1단계: 그룹핑 +엑셀의 플랫 행을 계층별 그룹으로 변환: +- Level 0 (거래처): customer_code 기준 그룹핑 +- Level 1 (품목매핑): customer_code + customer_item_code 기준 그룹핑 +- Level 2 (단가): 매 행마다 INSERT + +### 2단계: 계단식 UPSERT (트랜잭션) +``` +BEGIN TRANSACTION + +FOR EACH unique customer_code: + 1. customer_mng UPSERT → 결과에서 id 획득 (returnedId) + + FOR EACH unique customer_item_code (해당 거래처): + 2. customer_item_mapping의 customer_id = returnedId 주입 + UPSERT → 결과에서 id 획득 (mappingId) + + FOR EACH price row (해당 품목매핑): + 3. customer_item_prices의 mapping_id = mappingId 주입 + INSERT + +COMMIT (전체 성공) or ROLLBACK (하나라도 실패) +``` + +### 3단계: 결과 반환 +```json +{ + "success": true, + "results": { + "customer_mng": { "inserted": 2, "updated": 1 }, + "customer_item_mapping": { "inserted": 5, "updated": 2 }, + "customer_item_prices": { "inserted": 12 } + }, + "errors": [] +} +``` + +## 테스트 계획 + +### 1단계: 백엔드 서비스 +- [x] plan.md 작성 +- [ ] multiTableExcelService.ts 기본 구조 작성 +- [ ] 그룹핑 로직 구현 +- [ ] 계단식 UPSERT 로직 구현 +- [ ] 트랜잭션 처리 +- [ ] 에러 핸들링 + +### 2단계: API 엔드포인트 +- [ ] POST /api/data/multi-table/upload 추가 +- [ ] POST /api/data/multi-table/template 추가 (템플릿 다운로드) +- [ ] 입력값 검증 + +### 3단계: 프론트엔드 +- [ ] MultiTableExcelUploadModal.tsx 컴포넌트 작성 +- [ ] 모드 선택 UI +- [ ] 템플릿 다운로드 버튼 +- [ ] 파일 업로드 + 미리보기 +- [ ] 컬럼 매핑 UI +- [ ] 업로드 결과 표시 + +### 4단계: 통합 +- [ ] 거래처 관리 화면에 연결 +- [ ] 실제 데이터로 테스트 + +## 진행 상태 +- 완료된 테스트는 [x]로 표시 +- 현재 진행 중인 테스트는 [진행중]으로 표시 diff --git a/frontend/components/common/MultiTableExcelUploadModal.tsx b/frontend/components/common/MultiTableExcelUploadModal.tsx new file mode 100644 index 00000000..867bdc14 --- /dev/null +++ b/frontend/components/common/MultiTableExcelUploadModal.tsx @@ -0,0 +1,786 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + ArrowRight, + Zap, + Download, + Loader2, +} from "lucide-react"; +import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport"; +import { cn } from "@/lib/utils"; +import { EditableSpreadsheet } from "./EditableSpreadsheet"; +import { + TableChainConfig, + uploadMultiTableExcel, +} from "@/lib/api/multiTableExcel"; + +export interface MultiTableExcelUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: TableChainConfig; + onSuccess?: () => void; +} + +interface ColumnMapping { + excelColumn: string; + targetColumn: string | null; +} + +export const MultiTableExcelUploadModal: React.FC = ({ + open, + onOpenChange, + config, + onSuccess, +}) => { + // 스텝: 1=모드선택+파일, 2=컬럼매핑, 3=확인 + const [currentStep, setCurrentStep] = useState(1); + + // 모드 선택 + const [selectedModeId, setSelectedModeId] = useState( + config.uploadModes[0]?.id || "" + ); + + // 파일 + const [file, setFile] = useState(null); + const [sheetNames, setSheetNames] = useState([]); + const [selectedSheet, setSelectedSheet] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + const [allData, setAllData] = useState[]>([]); + const [displayData, setDisplayData] = useState[]>([]); + const [excelColumns, setExcelColumns] = useState([]); + + // 매핑 + const [columnMappings, setColumnMappings] = useState([]); + + // 업로드 + const [isUploading, setIsUploading] = useState(false); + + const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId); + + // 선택된 모드에서 활성화되는 컬럼 목록 + const activeColumns = React.useMemo(() => { + if (!selectedMode) return []; + const cols: Array<{ dbColumn: string; excelHeader: string; required: boolean; levelLabel: string }> = []; + for (const levelIdx of selectedMode.activeLevels) { + const level = config.levels[levelIdx]; + if (!level) continue; + for (const col of level.columns) { + cols.push({ + ...col, + levelLabel: level.label, + }); + } + } + return cols; + }, [selectedMode, config.levels]); + + // 템플릿 다운로드 + const handleDownloadTemplate = () => { + if (!selectedMode) return; + + const headers: string[] = []; + const sampleRow: Record = {}; + const sampleRow2: Record = {}; + + for (const levelIdx of selectedMode.activeLevels) { + const level = config.levels[levelIdx]; + if (!level) continue; + for (const col of level.columns) { + headers.push(col.excelHeader); + sampleRow[col.excelHeader] = col.required ? "(필수)" : ""; + sampleRow2[col.excelHeader] = ""; + } + } + + // 예시 데이터 생성 (config에 맞춰) + exportToExcel( + [sampleRow, sampleRow2], + `${config.name}_${selectedMode.label}_템플릿.xlsx`, + "Sheet1" + ); + + toast.success("템플릿 파일이 다운로드되었습니다."); + }; + + // 파일 처리 + const processFile = async (selectedFile: File) => { + const ext = selectedFile.name.split(".").pop()?.toLowerCase(); + if (!["xlsx", "xls", "csv"].includes(ext || "")) { + toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); + return; + } + + setFile(selectedFile); + + try { + const sheets = await getExcelSheetNames(selectedFile); + setSheetNames(sheets); + setSelectedSheet(sheets[0] || ""); + + const data = await importFromExcel(selectedFile, sheets[0]); + setAllData(data); + setDisplayData(data); + + if (data.length > 0) { + setExcelColumns(Object.keys(data[0])); + } + + toast.success(`파일 선택 완료: ${selectedFile.name}`); + } catch (error) { + console.error("파일 읽기 오류:", error); + toast.error("파일을 읽는 중 오류가 발생했습니다."); + setFile(null); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) await processFile(selectedFile); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + const droppedFile = e.dataTransfer.files?.[0]; + if (droppedFile) await processFile(droppedFile); + }; + + const handleSheetChange = async (sheetName: string) => { + setSelectedSheet(sheetName); + if (!file) return; + + try { + const data = await importFromExcel(file, sheetName); + setAllData(data); + setDisplayData(data); + if (data.length > 0) { + setExcelColumns(Object.keys(data[0])); + } + } catch (error) { + console.error("시트 읽기 오류:", error); + toast.error("시트를 읽는 중 오류가 발생했습니다."); + } + }; + + // 2단계 진입 시 자동 매핑 시도 + useEffect(() => { + if (currentStep === 2 && excelColumns.length > 0) { + performAutoMapping(); + } + }, [currentStep]); + + const performAutoMapping = () => { + const newMappings: ColumnMapping[] = excelColumns.map((excelCol) => { + const normalizedExcel = excelCol.toLowerCase().trim(); + const matched = activeColumns.find((ac) => { + return ( + ac.excelHeader.toLowerCase().trim() === normalizedExcel || + ac.dbColumn.toLowerCase().trim() === normalizedExcel + ); + }); + return { + excelColumn: excelCol, + targetColumn: matched ? matched.excelHeader : null, + }; + }); + setColumnMappings(newMappings); + + const matchedCount = newMappings.filter((m) => m.targetColumn).length; + if (matchedCount > 0) { + toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`); + } + }; + + const handleMappingChange = (excelColumn: string, targetColumn: string | null) => { + setColumnMappings((prev) => + prev.map((m) => + m.excelColumn === excelColumn ? { ...m, targetColumn } : m + ) + ); + }; + + // 업로드 실행 + const handleUpload = async () => { + if (!file || !selectedMode) return; + + setIsUploading(true); + + try { + // 엑셀 데이터를 excelHeader 기준으로 변환 + const mappedRows = allData.map((row) => { + const mappedRow: Record = {}; + columnMappings.forEach((mapping) => { + if (mapping.targetColumn) { + mappedRow[mapping.targetColumn] = row[mapping.excelColumn]; + } + }); + return mappedRow; + }); + + // 빈 행 필터링 + const filteredRows = mappedRows.filter((row) => + Object.values(row).some( + (v) => v !== undefined && v !== null && (typeof v !== "string" || v.trim() !== "") + ) + ); + + console.log(`다중 테이블 업로드: ${filteredRows.length}행`); + + const result = await uploadMultiTableExcel({ + config, + modeId: selectedModeId, + rows: filteredRows, + }); + + if (result.success && result.data) { + const { results, errors } = result.data; + const summaryParts = results + .filter((r) => r.inserted + r.updated > 0) + .map((r) => { + const parts: string[] = []; + if (r.inserted > 0) parts.push(`신규 ${r.inserted}건`); + if (r.updated > 0) parts.push(`수정 ${r.updated}건`); + return `${r.tableName}: ${parts.join(", ")}`; + }); + + const msg = summaryParts.join(" / "); + const errorMsg = errors.length > 0 ? ` (오류: ${errors.length}건)` : ""; + + toast.success(`업로드 완료: ${msg}${errorMsg}`); + + if (errors.length > 0) { + console.warn("업로드 오류 목록:", errors); + } + + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message || "업로드에 실패했습니다."); + } + } catch (error) { + console.error("다중 테이블 업로드 실패:", error); + toast.error("업로드 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다음/이전 단계 + const handleNext = () => { + if (currentStep === 1) { + if (!file) { + toast.error("파일을 선택해주세요."); + return; + } + if (displayData.length === 0) { + toast.error("데이터가 없습니다."); + return; + } + } + + if (currentStep === 2) { + // 필수 컬럼 매핑 확인 + const mappedTargets = new Set( + columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn) + ); + const unmappedRequired = activeColumns + .filter((ac) => ac.required && !mappedTargets.has(ac.excelHeader)) + .map((ac) => `${ac.excelHeader}`); + + if (unmappedRequired.length > 0) { + toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`); + return; + } + } + + setCurrentStep((prev) => Math.min(prev + 1, 3)); + }; + + const handlePrevious = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + // 모달 닫기 시 초기화 + useEffect(() => { + if (!open) { + setCurrentStep(1); + setSelectedModeId(config.uploadModes[0]?.id || ""); + setFile(null); + setSheetNames([]); + setSelectedSheet(""); + setAllData([]); + setDisplayData([]); + setExcelColumns([]); + setColumnMappings([]); + } + }, [open, config.uploadModes]); + + return ( + + + + + + {config.name} - 엑셀 업로드 + + 다중 테이블 + + + + {config.description} + + + + {/* 스텝 인디케이터 */} +
+ {[ + { num: 1, label: "모드 선택 / 파일" }, + { num: 2, label: "컬럼 매핑" }, + { num: 3, label: "확인" }, + ].map((step, index) => ( + +
+
step.num + ? "bg-success text-white" + : "bg-muted text-muted-foreground" + )} + > + {currentStep > step.num ? ( + + ) : ( + step.num + )} +
+ + {step.label} + +
+ {index < 2 && ( +
step.num ? "bg-success" : "bg-muted" + )} + /> + )} + + ))} +
+ + {/* 스텝별 컨텐츠 */} +
+ {/* 1단계: 모드 선택 + 파일 선택 */} + {currentStep === 1 && ( +
+ {/* 업로드 모드 선택 */} +
+ +
+ {config.uploadModes.map((mode) => ( + + ))} +
+
+ + {/* 템플릿 다운로드 */} +
+
+ + 선택한 모드에 맞는 엑셀 양식을 다운로드하세요 +
+ +
+ + {/* 파일 선택 */} +
+ +
fileInputRef.current?.click()} + className={cn( + "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors", + isDragOver + ? "border-primary bg-primary/5" + : file + ? "border-green-500 bg-green-50" + : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50" + )} + > + {file ? ( +
+ +
+

{file.name}

+

+ 클릭하여 다른 파일 선택 +

+
+
+ ) : ( + <> + +

+ {isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"} +

+

+ 지원 형식: .xlsx, .xls, .csv +

+ + )} + +
+
+ + {/* 미리보기 */} + {file && displayData.length > 0 && ( + <> +
+
+ + +
+ + {displayData.length}개 행 + +
+ + { + setDisplayData(newData); + setAllData(newData); + }} + maxHeight="250px" + /> + + )} +
+ )} + + {/* 2단계: 컬럼 매핑 */} + {currentStep === 2 && ( +
+
+

컬럼 매핑 설정

+ +
+ +
+
+
엑셀 컬럼
+
+
시스템 컬럼
+
+ +
+ {columnMappings.map((mapping, index) => ( +
+
+ {mapping.excelColumn} +
+ + +
+ ))} +
+
+ + {/* 미매핑 필수 컬럼 경고 */} + {(() => { + const mappedTargets = new Set( + columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn) + ); + const missing = activeColumns.filter( + (ac) => ac.required && !mappedTargets.has(ac.excelHeader) + ); + if (missing.length === 0) return null; + return ( +
+
+ +
+

필수 컬럼이 매핑되지 않았습니다:

+

+ {missing.map((m) => `[${m.levelLabel}] ${m.excelHeader}`).join(", ")} +

+
+
+
+ ); + })()} + + {/* 모드 정보 */} + {selectedMode && ( +
+
+ +
+

모드: {selectedMode.label}

+

+ 대상 테이블:{" "} + {selectedMode.activeLevels + .map((i) => config.levels[i]?.label) + .filter(Boolean) + .join(" → ")} +

+
+
+
+ )} +
+ )} + + {/* 3단계: 확인 */} + {currentStep === 3 && ( +
+
+

업로드 요약

+
+

파일: {file?.name}

+

시트: {selectedSheet}

+

데이터 행: {allData.length}개

+

모드: {selectedMode?.label}

+

+ 대상 테이블:{" "} + {selectedMode?.activeLevels + .map((i) => { + const level = config.levels[i]; + return level + ? `${level.label}(${level.tableName})` + : ""; + }) + .filter(Boolean) + .join(" → ")} +

+
+
+ +
+

컬럼 매핑

+
+ {columnMappings + .filter((m) => m.targetColumn) + .map((mapping, idx) => { + const ac = activeColumns.find( + (c) => c.excelHeader === mapping.targetColumn + ); + return ( +

+ {mapping.excelColumn}{" "} + → [{ac?.levelLabel}] {mapping.targetColumn} +

+ ); + })} +
+
+ +
+
+ +
+

주의사항

+

+ 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. + 같은 키 값의 기존 데이터는 업데이트됩니다. +

+
+
+
+
+ )} +
+ + + + {currentStep < 3 ? ( + + ) : ( + + )} + + +
+ ); +}; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index f4917d62..126f6a4d 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -851,6 +851,7 @@ export const ButtonConfigPanel: React.FC = ({ {/* 엑셀 관련 */} 엑셀 다운로드 엑셀 업로드 + 다중 테이블 엑셀 업로드 {/* 고급 기능 */} 즉시 저장 @@ -2430,6 +2431,8 @@ export const ButtonConfigPanel: React.FC = ({ /> )} + {/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */} + {/* 바코드 스캔 액션 설정 */} {localInputs.actionType === "barcode_scan" && (
@@ -3997,8 +4000,8 @@ export const ButtonConfigPanel: React.FC = ({ )}
- {/* 제어 기능 섹션 - 엑셀 업로드가 아닐 때만 표시 */} - {localInputs.actionType !== "excel_upload" && ( + {/* 제어 기능 섹션 - 엑셀 업로드 계열이 아닐 때만 표시 */} + {localInputs.actionType !== "excel_upload" && localInputs.actionType !== "multi_table_excel_upload" && (
@@ -4687,3 +4690,4 @@ const ExcelUploadConfigSection: React.FC<{ ); }; + diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 877b1523..92fb7341 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -365,6 +365,14 @@ export const V2RepeaterConfigPanel: React.FC = ({ fetchEntityJoinColumns(); }, [entityJoinTargetTable]); + // 설정 업데이트 헬퍼 + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + // Entity 조인 컬럼 토글 (추가/제거) const toggleEntityJoinColumn = useCallback( (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { @@ -423,14 +431,6 @@ export const V2RepeaterConfigPanel: React.FC = ({ [config.entityJoins], ); - // 설정 업데이트 헬퍼 - const updateConfig = useCallback( - (updates: Partial) => { - onChange({ ...config, ...updates }); - }, - [config, onChange], - ); - const updateDataSource = useCallback( (field: string, value: any) => { updateConfig({ diff --git a/frontend/lib/api/multiTableExcel.ts b/frontend/lib/api/multiTableExcel.ts new file mode 100644 index 00000000..faa75a2b --- /dev/null +++ b/frontend/lib/api/multiTableExcel.ts @@ -0,0 +1,98 @@ +/** + * 다중 테이블 엑셀 업로드 API 클라이언트 + */ +import { apiClient } from "./client"; + +/** 테이블 계층 레벨 설정 */ +export interface TableLevel { + tableName: string; + label: string; + parentFkColumn?: string; + parentRefColumn?: string; + upsertMode: "upsert" | "insert"; + upsertKeyColumns?: string[]; + columns: ColumnDef[]; +} + +/** 컬럼 정의 */ +export interface ColumnDef { + dbColumn: string; + excelHeader: string; + required: boolean; + defaultValue?: any; +} + +/** 업로드 모드 정의 */ +export interface UploadMode { + id: string; + label: string; + description: string; + activeLevels: number[]; +} + +/** 테이블 체인 설정 */ +export interface TableChainConfig { + id: string; + name: string; + description: string; + levels: TableLevel[]; + uploadModes: UploadMode[]; +} + +/** 레벨별 결과 */ +export interface LevelResult { + tableName: string; + inserted: number; + updated: number; +} + +/** 업로드 결과 */ +export interface MultiTableUploadResult { + success: boolean; + results: LevelResult[]; + totalRows: number; + errors: string[]; +} + +/** + * 루트 테이블명으로 TableChainConfig를 자동 감지 + * DB FK 관계 + 컬럼 메타데이터를 분석하여 실시간 생성 + */ +export async function autoDetectMultiTableConfig( + rootTable: string, + screenId?: number +): Promise<{ success: boolean; data?: TableChainConfig; message?: string }> { + try { + const params: Record = { rootTable }; + if (screenId) params.screenId = screenId; + + const response = await apiClient.get("/data/multi-table/auto-detect", { + params, + }); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } +} + +/** + * 다중 테이블 엑셀 업로드 실행 + */ +export async function uploadMultiTableExcel(params: { + config: TableChainConfig; + modeId: string; + rows: Record[]; +}): Promise<{ success: boolean; data?: MultiTableUploadResult; message?: string }> { + try { + const response = await apiClient.post("/data/multi-table/upload", params); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } +} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 5a839620..0cdf6836 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1235,6 +1235,7 @@ export const SplitPanelLayoutComponent: React.FC if (!leftItem) return; setIsLoadingRight(true); + setRightData([]); try { // detail / join 모두 동일한 필터링 로직 사용 // (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함) @@ -1343,34 +1344,12 @@ export const SplitPanelLayoutComponent: React.FC enableEntityJoin: true, size: 1000, companyCodeOverride: companyCode, - additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달 + additionalJoinColumns: rightJoinColumns, }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); - // 추가 dataFilter 적용 - let filteredData = result.data || []; - const dataFilter = componentConfig.rightPanel?.dataFilter; - if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { - filteredData = filteredData.filter((item: any) => { - return dataFilter.conditions.every((cond: any) => { - const value = item[cond.column]; - const condValue = cond.value; - switch (cond.operator) { - case "equals": - return value === condValue; - case "notEquals": - return value !== condValue; - case "contains": - return String(value).includes(String(condValue)); - default: - return true; - } - }); - }); - } - - setRightData(filteredData); + setRightData(result.data || []); } else { // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; @@ -1380,9 +1359,11 @@ export const SplitPanelLayoutComponent: React.FC const leftValue = leftItem[leftColumn]; const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 단일키를 복합키 형식으로 변환 + console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName }); + + // 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수) const searchConditions: Record = {}; - searchConditions[rightColumn] = leftValue; + searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; // Entity 조인 컬럼 추출 const rightJoinColumnsLegacy = extractAdditionalJoinColumns( @@ -1401,35 +1382,13 @@ export const SplitPanelLayoutComponent: React.FC additionalJoinColumns: rightJoinColumnsLegacy, }); - let filteredDataLegacy = result.data || []; - - // 데이터 필터 적용 - const dataFilterLegacy = componentConfig.rightPanel?.dataFilter; - if (dataFilterLegacy?.enabled && dataFilterLegacy.conditions?.length > 0) { - filteredDataLegacy = filteredDataLegacy.filter((item: any) => { - return dataFilterLegacy.conditions.every((cond: any) => { - const value = item[cond.column]; - const condValue = cond.value; - switch (cond.operator) { - case "equals": - return value === condValue; - case "notEquals": - return value !== condValue; - case "contains": - return String(value).includes(String(condValue)); - default: - return true; - } - }); - }); - } - - setRightData(filteredDataLegacy || []); + setRightData(result.data || []); } } } } catch (error) { console.error("우측 데이터 로드 실패:", error); + setRightData([]); toast({ title: "데이터 로드 실패", description: "우측 패널 데이터를 불러올 수 없습니다.", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index e72c19df..165e796d 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -49,6 +49,7 @@ export type ButtonActionType = | "view_table_history" // 테이블 이력 보기 | "excel_download" // 엑셀 다운로드 | "excel_upload" // 엑셀 업로드 + | "multi_table_excel_upload" // 다중 테이블 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 @@ -428,6 +429,9 @@ export class ButtonActionExecutor { case "excel_upload": return await this.handleExcelUpload(config, context); + case "multi_table_excel_upload": + return await this.handleMultiTableExcelUpload(config, context); + case "barcode_scan": return await this.handleBarcodeScan(config, context); @@ -5604,6 +5608,69 @@ export class ButtonActionExecutor { } } + /** + * 다중 테이블 엑셀 업로드 액션 처리 + */ + private static async handleMultiTableExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + const rootTable = context.tableName; + + if (!rootTable) { + toast.error("화면에 테이블이 설정되지 않았습니다. 화면 설정을 확인하세요."); + return false; + } + + toast.loading("테이블 구조를 분석하고 있습니다...", { id: "multi-table-detect" }); + + const { autoDetectMultiTableConfig } = await import("@/lib/api/multiTableExcel"); + const result = await autoDetectMultiTableConfig(rootTable, context.screenId); + + toast.dismiss("multi-table-detect"); + + if (!result.success || !result.data) { + toast.error(result.message || `테이블 구조를 분석할 수 없습니다: ${rootTable}`); + return false; + } + + const chainConfig = result.data; + + const { MultiTableExcelUploadModal } = await import("@/components/common/MultiTableExcelUploadModal"); + const { createRoot } = await import("react-dom/client"); + + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(MultiTableExcelUploadModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + config: chainConfig, + onSuccess: () => { + context.onRefresh?.(); + }, + }), + ); + + return true; + } catch (error) { + toast.dismiss("multi-table-detect"); + console.error("다중 테이블 엑셀 업로드 모달 열기 실패:", error); + showErrorToast(config.errorMessage || "다중 테이블 엑셀 업로드 화면을 열 수 없습니다", error, { + guidance: "잠시 후 다시 시도해 주세요.", + }); + return false; + } + } + /** * 바코드 스캔 액션 처리 */ @@ -7700,6 +7767,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record[]).map((row) => { + const newRow: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date && !isNaN(value.getTime())) { + const y = value.getUTCFullYear(); + const m = String(value.getUTCMonth() + 1).padStart(2, "0"); + const d = String(value.getUTCDate()).padStart(2, "0"); + newRow[key] = `${y}-${m}-${d}`; + } else { + newRow[key] = value; + } + } + return newRow; }); console.log("✅ 엑셀 가져오기 완료:", { sheetName: targetSheetName, - rowCount: jsonData.length, + rowCount: processedData.length, }); - resolve(jsonData as Record[]); + resolve(processedData); } catch (error) { console.error("❌ 엑셀 가져오기 실패:", error); reject(error); From 93eaf5996654c58a6796b87faaa13628b6907eca Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Mar 2026 19:27:51 +0900 Subject: [PATCH 2/2] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node