From d90a403ed9c2012fc297772ed8c5a6c4563241e8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 11:09:40 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/dynamicFormService.ts | 19 ++ .../src/services/tableCategoryValueService.ts | 214 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 15 +- 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 68c30252..8337ed74 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,6 +1,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; +import tableCategoryValueService from "./tableCategoryValueService"; export interface FormDataResult { id: number; @@ -427,6 +428,24 @@ export class DynamicFormService { dataToInsert, }); + // 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원) + console.log("🏷️ 카테고리 라벨→코드 변환 시작..."); + const companyCodeForCategory = company_code || "*"; + const { convertedData: categoryConvertedData, conversions } = + await tableCategoryValueService.convertCategoryLabelsToCodesForData( + tableName, + companyCodeForCategory, + dataToInsert + ); + + if (conversions.length > 0) { + console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions); + // 변환된 데이터로 교체 + Object.assign(dataToInsert, categoryConvertedData); + } else { + console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)"); + } + // 테이블 컬럼 정보 조회하여 타입 변환 적용 console.log("🔍 테이블 컬럼 정보 조회 중..."); const columnInfo = await this.getTableColumnInfo(tableName); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1638a417..edeb55b2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1398,6 +1398,220 @@ class TableCategoryValueService { throw error; } } + + /** + * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용) + * + * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체 + */ + async getCategoryLabelToCodeMapping( + tableName: string, + companyCode: string + ): Promise>> { + try { + logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode }); + + const pool = getPool(); + + // 1. 해당 테이블의 카테고리 타입 컬럼 조회 + const categoryColumnsQuery = ` + SELECT column_name + FROM table_type_columns + WHERE table_name = $1 + AND input_type = 'category' + `; + const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]); + + if (categoryColumnsResult.rows.length === 0) { + logger.info("카테고리 타입 컬럼 없음", { tableName }); + return {}; + } + + const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name); + logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns }); + + // 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회 + const result: Record> = {}; + + for (const columnName of categoryColumns) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + params = [tableName, columnName]; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + AND (company_code = $3 OR company_code = '*') + `; + params = [tableName, columnName, companyCode]; + } + + const valuesResult = await pool.query(query, params); + + // { [label]: code } 형태로 변환 + const labelToCodeMap: Record = {}; + for (const row of valuesResult.rows) { + // 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑 + labelToCodeMap[row.value_label] = row.value_code; + // 소문자 키도 추가 (대소문자 무시 검색용) + labelToCodeMap[row.value_label.toLowerCase()] = row.value_code; + } + + if (Object.keys(labelToCodeMap).length > 0) { + result[columnName] = labelToCodeMap; + logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`); + } + } + + logger.info(`카테고리 라벨→코드 매핑 조회 완료`, { + tableName, + columnCount: Object.keys(result).length + }); + + return result; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 데이터의 카테고리 라벨 값을 코드 값으로 변환 + * + * 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @param data - 변환할 데이터 객체 + * @returns 라벨이 코드로 변환된 데이터 객체 + */ + async convertCategoryLabelsToCodesForData( + tableName: string, + companyCode: string, + data: Record + ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> { + try { + // 라벨→코드 매핑 조회 + const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode); + + if (Object.keys(labelToCodeMapping).length === 0) { + // 카테고리 컬럼 없음 + return { convertedData: data, conversions: [] }; + } + + const convertedData = { ...data }; + const conversions: Array<{ column: string; label: string; code: string }> = []; + + for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) { + const value = data[columnName]; + + if (value !== undefined && value !== null && value !== "") { + const stringValue = String(value).trim(); + + // 다중 값 확인 (쉼표로 구분된 경우) + if (stringValue.includes(",")) { + // 다중 카테고리 값 처리 + const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== ""); + const convertedCodes: string[] = []; + let allConverted = true; + + for (const label of labels) { + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[label]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[label.toLowerCase()]; + } + + if (matchedCode) { + convertedCodes.push(matchedCode); + conversions.push({ + column: columnName, + label: label, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 + const isAlreadyCode = Object.values(labelCodeMap).includes(label); + if (isAlreadyCode) { + // 이미 코드값이면 그대로 사용 + convertedCodes.push(label); + } else { + // 라벨도 코드도 아니면 원래 값 유지 + convertedCodes.push(label); + allConverted = false; + logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`); + } + } + } + + // 변환된 코드들을 쉼표로 합쳐서 저장 + convertedData[columnName] = convertedCodes.join(","); + logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`); + } else { + // 단일 값 처리 + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[stringValue]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[stringValue.toLowerCase()]; + } + + if (matchedCode) { + // 라벨 값을 코드 값으로 변환 + convertedData[columnName] = matchedCode; + conversions.push({ + column: columnName, + label: stringValue, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 (역방향 확인) + const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue); + if (!isAlreadyCode) { + logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`); + } + // 변환 없이 원래 값 유지 + } + } + } + } + + logger.info(`카테고리 라벨→코드 변환 완료`, { + tableName, + conversionCount: conversions.length, + conversions, + }); + + return { convertedData, conversions }; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error }); + // 실패 시 원본 데이터 반환 + return { convertedData: data, conversions: [] }; + } + } } export default new TableCategoryValueService(); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index a4a17274..28be5688 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -307,10 +307,23 @@ export const ExcelUploadModal: React.FC = ({ return mappedRow; }); + // 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외 + const filteredData = mappedData.filter((row) => { + const values = Object.values(row); + // 하나라도 유효한 값이 있는지 확인 + return values.some((value) => { + if (value === undefined || value === null) return false; + if (typeof value === "string" && value.trim() === "") return false; + return true; + }); + }); + + console.log(`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`); + let successCount = 0; let failCount = 0; - for (const row of mappedData) { + for (const row of filteredData) { try { if (uploadMode === "insert") { const formData = { screenId: 0, tableName, data: row };