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);